Appearance
Production checklist
Work through this before your first real-volume send. Every item exists because getting it wrong costs printed paper.
Credentials
- [ ] Key stored in a secret manager or environment variable — never in source control, client-side code or a browser.
- [ ] Separate keys per workload (sending, reporting), so a leak is contained and one workload cannot exhaust another's per-key rate limit. Note all keys share the account ceiling, so extra keys isolate budgets rather than adding throughput.
- [ ] Only the permissions each key needs. In particular, only the sending service holds
campaigns:create, and nothing holdsqr-codes:deleteunless you genuinely need it. - [ ] A documented revoke-and-rotate procedure (web app → API page).
- [ ] Expiry noted in your calendar if the key was created with one — an expired key returns 401 and no amount of retrying helps.
Idempotency
- [ ]
Idempotency-Keysent on everyPOST /api/v1/campaigns. - [ ] The key is generated once per send intent and persisted before the request, so a crash-and-restart reuses it.
- [ ] Retries reuse the same key; a new key is never generated inside a retry loop.
- [ ] Your code branches on
201vs200to know whether it created or replayed. - [ ] No key is ever reused for a logically different send — the payload is not compared, so a reused key silently ignores the new body.
See Idempotency.
Content
- [ ] Templates authored and visually approved in the web app.
- [ ] Template ids pinned in configuration, not resolved by name at send time.
- [ ] No dependence on raw editor
content— it is unsupported and will be rejected for API keys in a future release. - [ ] For PDF sends: A4, correct page count, embedded fonts, and the top 77 mm of page 1 kept clear of artwork. The API does not check any of this. See PDF campaigns.
- [ ] At least one real proof letter posted to your own address per layout, and inspected on paper.
Recipient data
- [ ] Addresses deduplicated before sending — nothing dedupes them for you, and two identical rows mean two letters.
- [ ]
import.imported(or the import endpoint'simported) compared against the count you expected, with an alert on the difference. - [ ]
skippedlogged and understood.{ "imported": 0, "skipped": 500 }is a 200 response that mailed nothing. - [ ] One stored record spot-checked after an import, to catch a swapped
columnIndexthat the counts alone would hide. - [ ] Column mappings validated against the list's schema before importing into an existing list.
- [ ] Missing-value policies set deliberately — the default is
skip, which drops rows silently.
Error handling
- [ ] Branch on
error.code, never onerror.message. - [ ] Retry only
rate_limitedandservice_unavailable; treat other 4xx as bugs in the request. - [ ]
Retry-Afterhonoured on 429 rather than guessed at. - [ ] Exponential backoff with jitter for everything else.
- [ ]
RateLimit-Remainingwatched so you slow down before being throttled. - [ ] 401 and 403 routed to an operator alert, not into the retry loop.
- [ ]
detailsaccessed defensively — many 400s are message-only:body.error.details?.fieldErrors ?? {}. - [ ] A 500 on campaign create retried only with the original
Idempotency-Key.
See Errors.
Polling
- [ ] Polling starts at ~2 s and backs off, capped around 30 s. Not a tight loop.
- [ ] An overall timeout that raises "check on this", not "it failed".
- [ ]
failednever triggers an automatic resend — some mail may already have printed. - [ ] Dashboards use
GET /api/v1/campaigns/status-countsrather than polling every campaign.
See Polling & failures.
Rate limits
- [ ] Bulk reads use
limit=100, not the default 20. - [ ] Bulk recipient loading uses a file import, not a loop of single adds.
- [ ] Large sweeps are paced below ~90 requests/minute.
- [ ] 429 handled everywhere, including inside polling loops.
Data lifecycle
- [ ] You know that a list is deleted after 90 days without activity, and that reading does not count as activity.
- [ ] Audiences you care about are held in your system as the source of truth.
- [ ] You accept that campaigns of a deleted list survive with
listId: null. - [ ] You know there is no update or delete for recipients or lists over the API.
See Lists & recipients.
QR codes
- [ ] Codes attached in the campaign create call, not afterwards.
- [ ]
idpersisted at create time — it is the only handle for retargeting later, and no screen in the web app shows it. - [ ]
slugpersisted too;trackingUrlis not returned again. - [ ] The redirect verified with a manual request before mailing.
- [ ]
trackingEnabled: trueif you want the campaign under Analys in the web app; landing-page analytics still needed if your own systems must hold the numbers (no scan API yet). - [ ] Deleting a QR code understood as permanent breakage of printed mail.
Telephony add-on
- [ ] Letters using the add-on carry the Telefonnummer field where the number should print — the web app warns, but does not refuse, without it.
- [ ] The forwarding number is one that answers during office hours; unanswered calls are logged as missed and the caller hears nothing else.
Configuration
- [ ] Base URL configurable per environment — no hardcoded
localhostaddress from local development left in your code. - [ ] Health check wired to
GET /apiso you can tell a bad URL from a bad key. - [ ] Request timeouts set. Campaign create with a large multipart body is slow; allow for it.
Before the first big send
- [ ] A send to exactly one recipient — your own address — completed and the letter inspected on paper.
- [ ] The whole failure path exercised deliberately: a bad row (422), a wrong id (404), a missing permission (403).
- [ ] An operator alert path that a human actually reads for
failedcampaigns. - [ ] Volume and cost confirmed. There is no cancel and no unsend once a campaign reaches
processing.
The last check is the one that matters
POST /api/v1/campaigns is irreversible and billable. Confirm the recipient count you are about to mail before the call, and log it with the campaign id. A mistyped filter that turns 250 letters into 25,000 is discovered too late.