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.

http://localhost:3000/api/v1/...

Replace the base URL

http://localhost:3000 is the local development address. Point your client at your own deployment's base URL — every example on these pages uses the local one purely so it can be copied and run immediately.

Authentication

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

bash
curl -H "x-api-key: drse_your_api_key" \
  http://localhost:3000/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