Appearance
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/campaignsA 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.
| Shape | Used 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
metaobject. Cursor-paginated endpoints usemeta: { count, nextCursor, prevCursor }; cursors arenullat the ends of the range.GET /api/v1/listsis not paginated and usesmeta: { 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"] } }
}
}codeis a stable enum. Branch oncode.messageis human-readable and may be reworded. Where a message is guaranteed, the reference page says so explicitly.detailsis present only where the reference page documents it.
Error codes
| Status | code | Meaning |
|---|---|---|
| 400 | validation_failed | Request shape or field validation failed, malformed JSON, an invalid Idempotency-Key, an undetectable recipients-file format, or an invalid file part |
| 401 | unauthorized | Missing, unknown, expired, disabled or exhausted key |
| 403 | forbidden | The key lacks the required permission, or the endpoint refuses API keys entirely |
| 404 | not_found | The id does not resolve to a resource in your account — or the route does not exist |
| 409 | conflict | State conflict, e.g. the campaign already has a QR code |
| 413 | payload_too_large | Body or file over its limit |
| 415 | unsupported_media_type | POST /api/v1/campaigns received neither JSON nor multipart |
| 422 | invalid_mapping | A recipients-file column mapping does not fit the list's field schema |
| 422 | recipient_validation_failed | One or more recipient rows failed validation |
| 422 | list_empty | A campaign needs at least one recipient and got none |
| 422 | unprocessable | Other semantic failures (reserved) |
| 402 | insufficient_credits | The account's prepaid letter balance is too small for this campaign. See Credits |
| 429 | rate_limited | A rate limit was exceeded — the key's, the account's, or your network's. Retry-After says how long to wait |
| 500 | internal_error | Unexpected server error |
| 503 | service_unavailable | The 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
| Page | Operations |
|---|---|
| Campaigns | Create a campaign, list, count by status, poll one |
| Lists | Create and read lists, read a list's field schema |
| Recipients | Read, add and bulk-import recipients |
| QR codes | Attach, read, update and delete a campaign's QR code |
| Templates | Discover the templates you authored in the web app |
| Public endpoints | Health check and the printed QR redirect |