Skip to content

Conventions ​

Everything on the reference pages follows the rules below. Read this page once and the rest of the reference becomes predictable.

Base URL ​

Every endpoint lives under /api. The versioned, developer-facing surface is under /api/v1.

https://api.utskick.dr.se/api/v1/...

Base URL

https://api.utskick.dr.se is the production API. Every example on these pages can be copied and run as-is; if you are developing against a local instance, substitute its address (for example http://localhost:3000).

Authentication ​

Send your key in the x-api-key header on every request:

bash
curl -H "x-api-key: drse_your_api_key" \
  https://api.utskick.dr.se/api/v1/campaigns

A request presenting an API key never falls back to cookie-session auth — a bad key returns its specific failure, not a generic 401. See Authentication for the full table and Permissions for what each key can reach.

Success responses ​

Every response body is JSON.

ShapeUsed by
{ "data": { ... } }a single resource, or an action result
{ "data": [ ... ], "meta": { ... } }collections
  • Creates return 201 with the created resource in data.
  • Deletes return 200 with { "data": { "deleted": true } }.
  • Collections carry a meta object. Cursor-paginated endpoints use meta: { count, nextCursor, prevCursor }; cursors are null at the ends of the range. GET /api/v1/lists is not paginated and uses meta: { totalRecipientCount, listCount } instead. See Pagination.
  • Timestamps are ISO 8601 strings in UTC (2026-07-25T10:00:00.000Z).
  • Identifiers are UUIDs. Campaign, list, recipient, template and QR-code ids are all UUIDv7, so they sort chronologically — which is what makes cursor pagination stable.

Error responses ​

Every error — including unknown routes — uses one shape:

json
{
  "error": {
    "code": "validation_failed",
    "message": "Request validation failed",
    "details": { "formErrors": [], "fieldErrors": { "name": ["Campaign name is required"] } }
  }
}
  • code is a stable enum. Branch on code.
  • message is human-readable and may be reworded. Where a message is guaranteed, the reference page says so explicitly.
  • details is present only where the reference page documents it.

Error codes ​

StatuscodeMeaning
400validation_failedRequest shape or field validation failed, malformed JSON, an invalid Idempotency-Key, an undetectable recipients-file format, or an invalid file part
401unauthorizedMissing, unknown, expired, disabled or exhausted key
403forbiddenThe key lacks the required permission, or the endpoint refuses API keys entirely
404not_foundThe id does not resolve to a resource in your account — or the route does not exist
409conflictState conflict, e.g. the campaign already has a QR code
413payload_too_largeBody or file over its limit
415unsupported_media_typePOST /api/v1/campaigns received neither JSON nor multipart
422invalid_mappingA recipients-file column mapping does not fit the list's field schema
422recipient_validation_failedOne or more recipient rows failed validation
422list_emptyA campaign needs at least one recipient and got none
422unprocessableOther semantic failures (reserved)
402insufficient_creditsThe account's prepaid letter balance is too small for this campaign. See Credits
429rate_limitedA rate limit was exceeded — the key's, the account's, or your network's. Retry-After says how long to wait
500internal_errorUnexpected server error
503service_unavailableThe service is shutting down or temporarily unavailable

Full explanations and handling advice: Errors.

Validation issues ​

422 responses about recipient rows carry details.issues, an array of the same shape everywhere it appears:

json
{ "row": 2, "field": "vip", "issue": "missing_field", "message": "Missing custom field \"vip\" (required by list schema)" }

issue is one of unexpected_field, missing_field, missing_required, duplicate_key. row is a 1-based data row index — header rows are not counted. For inline recipients it is the position in the array you sent.

Not-found messages ​

These strings are fixed and safe to match on:

List not found · Template not found · Campaign not found · QR code not found · Route not found

The id you sent is never echoed back. A resource owned by another account is indistinguishable from one that does not exist — both are 404. 403 is only ever about permissions, never about resource access.

Rate-limit headers ​

Every /api/v1 response reports your current position, so you can pace without waiting to be throttled:

http
RateLimit-Limit: 100
RateLimit-Remaining: 12
RateLimit-Reset: 34
RateLimit-Policy: 100;w=60;name="key", 300;w=60;name="account"

Limit/Remaining/Reset describe whichever policy is closest to running out — the one that will stop you next — so RateLimit-Remaining alone is enough to pace against, and it never exceeds RateLimit-Limit. RateLimit-Policy lists every policy's sustained quota.

RateLimit-Limit is what is available at once, so for a burst-capable policy it is smaller than that policy's per-window quota: an idle account reads RateLimit-Limit: 61 while RateLimit-Policy advertises 300;w=60. On a 429, Retry-After gives the exact seconds to wait. All five headers are CORS-exposed for browser clients. Full detail: Rate limits.

Two behaviours worth knowing up front ​

Auth runs before route matching. On a guarded path, an unauthenticated request to a wrong method or a mistyped sub-path returns the 401 envelope rather than 404. If you get an unexpected 401, check your key before you check your URL.

Unknown routes still return JSON. A typo'd path returns { "error": { "code": "not_found", "message": "Route not found" } }, not an HTML or plain-text error page.

Reference pages ​

PageOperations
CampaignsCreate a campaign, list, count by status, poll one
ListsCreate and read lists, read a list's field schema
RecipientsRead, add and bulk-import recipients
QR codesAttach, read, update and delete a campaign's QR code
TemplatesDiscover the templates you authored in the web app
Public endpointsHealth check and the printed QR redirect