API Authentication
API keys
Format: sok_live_<32-char-nanoid> or sok_test_<32-char-nanoid>.
Generated in src/lib/auth/api-key.ts:
const random = nanoid(32);
const fullKey = `${prefix}${random}`;
const keyHash = sha256(fullKey); // stored
const lastFour = random.slice(-4); // stored for display
Storage: the full key is never persisted — only key_hash (sha256) + last_four + key_prefix.
Create a key
From the dashboard: /overview/api-keys. Internally calls POST /api/internal/api-keys.
{ "name": "Production server", "environment": "live" }
Response — full key is returned once and never shown again:
{ "id": "key_...", "key": "sok_live_...", "name": "...", "environment": "live" }
Usage
curl -H "Authorization: Bearer sok_live_..." https://host/api/v1/emails
On every request, validateApiKey():
- Rejects keys without a
sok_live_/sok_test_prefix. Legacymr_live_/mr_test_keys minted before the rebrand are still accepted and behave identically; new keys are always issuedsok_. - Computes sha256 of incoming key, looks up
api_keyswherekey_hashmatches ANDrevoked_at IS NULL. - Updates
last_used_atfire-and-forget.
Environments
| Prefix | Behavior |
|---|---|
sok_live_ |
Real AWS provider call, usage limit enforced, metered to billing period |
sok_test_ |
Provider call skipped (providerMessageId = "test_msg_..."), usage limit bypassed |
What test mode does — and doesn't — simulate
Test mode is send-suppression, not a separate sandbox. A sok_test_ send:
Skips the provider. No SES/SNS call;
provider_responseis{ "simulated": true }. Nothing reaches a real inbox or phone — see it in the dashboard Test inbox.Writes a real message row with
environment: "test". Test and live data are strictly isolated: a test key can never read live messages, and vice versa.Fires your webhooks (
message.sentetc.) signed with your real secret — by design, so you can integration-test your receiver end-to-end. Every event a test send causes says so:"environment": "test"in the body andX-Sendoka-Environment: teston the request, for the send's own events and for the ones that follow it — opens, clicks, and a test blast'sjob.started/job.completed/job.canceled. Route on that header to keep rehearsals out of production handling (events).Never counts against your endpoint's health. An endpoint is switched off after 10 deliveries in a row exhaust their retries; test-mode deliveries (test sends' events, test-fire, replays of either) are left out of that count entirely, so a receiver that answers test events with a
4xx— as routing on the header above suggests — cannot get your live endpoint disabled.Respects suppressions, templates, scheduling, and validation exactly like live — a payload that 422s in test 422s in live.
Simulates 10DLC / toll-free provisioning.
POST /v1/brands,/v1/campaignsand/v1/phone-numberswith a test key never reach The Campaign Registry or AWS: no billedCreateRegistration, no leased number, nothing orphaned at the provider when your test tears the row down. The row comes back immediately asstatus: "verified"with atest-reg-…provider id, an emptyprovider_missing_fields, and (for numbers) an unroutable+1555…E.164. Test mode therefore cannot tell you whether a real brand would be approved, or which identity fields the registry would demand — register one brand with a live key before launch.Simulated resources stay out of live paths. A
test-reg-…number cannot be used as a live sender, added to a messaging pool, or bound to a live campaign, and a test-mode brand or campaign cannot back a live registration. Nor can one hold a sender ID: an alphanumeric sender ID registered with a test key is taken over by the first live registration of the same ID, which replaces the test-mode row instead of answering409 SENDER_ALREADY_REGISTERED. A messaging pool has no environment of its own — one pool row serves every send — so a simulated number is refused there outright rather than quietly routing a live message nowhere.Never touches billing. Test usage is metered separately (
GET /v1/usagewith a test key shows it), bypasses plan limits, and is excluded from overage reporting.Cannot change anything live traffic shares. Only messages, API keys and verifications record which mode they belong to (10DLC registrations are told apart by their
test-reg-…ids). Everything else a send reads is one row serving both modes: one webhook endpoint receives live and test events, and live sends render the same templates, read the same suppressions, draw from the same messaging pools, and blast the same audiences and contacts under the same tenants. A test key can read all of these and send with them, but every write to them answers403 ENVIRONMENT_ESCALATION, before the request body is read:- webhook endpoints — create, update, delete, rotate the secret;
- templates — create, update, delete. Creating is refused too because a tenant's template takes precedence over the org's of the same slug, so a new tenant-scoped row would replace the org's template in that tenant's live sends;
- suppressions — add, remove;
- messaging pools — create, update, delete. A tenant's pool takes precedence over the org's of the same slug, the same way;
- audiences and contacts — create, rename or delete an audience, add contacts to one, create, update or delete a contact. A live blast mails whoever is on the list, at the address and with the name and metadata the contact row holds;
- tenants — create, archive, restore, suspend, unsuspend, erase, set a quota. That includes the implicit create: an
X-Sendoka-Tenant-Refthat matches no tenant is refused rather than auto-created (a live key holdingwrite:tenants, or a full-access one, still auto-creates). A ref that matches an existing, active tenant resolves exactly as it does for a live key, so test sends on behalf of a tenant keep working; - API keys — creating any key, test or live, and changing or revoking a live one. A test key still lists, changes and revokes test keys. Minting is refused for test keys too because every mint spends two budgets shared with live — the org's key-creation limit (10 an hour, shared with the dashboard) and a slot of your plan's key cap, which counts keys of both modes — and a key cannot record which key made it. Create keys in the dashboard or with a live key.
Do those with a live key; a scoped one is enough (
write:webhooks,write:templates,write:suppressions,write:pools,write:audiences,write:contacts,write:tenants). A leaked test key therefore cannot subscribe itself to your live event stream, rewrite your live mail, re-route your live SMS, add recipients to or remove them from your live lists, or re-enable addresses that bounced or unsubscribed.Still does everything a test needs. Every send (
/v1/emails,/v1/sms, both batches,/v1/verifications, andPOST /v1/audiences/{id}/sendto an audience that already exists), cancelling its own test sends and blast jobs, every read, checking its own test verifications, simulated 10DLC provisioning, validation (/v1/validate/*), webhook test-fire, and replaying test-mode deliveries.Has limits of its own. Test traffic never spends a budget live traffic runs on. A test key is rated in its own per-minute API window, the same size as your plan's (rate limits), so a CI suite at full speed cannot put your live requests at
429; and it has its own buckets for verification codes (5 an hour per destination, 1,000 an hour per org), webhook test-fire (20 a minute per endpoint), webhook replay (10 a minute per endpoint) and simulated 10DLC provisioning (10 an hour). Hitting any of them answers the usual429for the test key alone.Writes at most so many messages a day. Free and cheap is not unlimited: every test message is still a row, a webhook fan-out and (tracked) a set of signed links. An organization's test keys may write 1,000 test-mode messages per UTC day on Free, 10,000 on Pay as you go and Pro, 100,000 on Enterprise — every single send, batch item (bulk: each recipient), blast recipient, test verification, and dashboard resend or template test send counts once, and a request is charged whole or refused whole with
429 TEST_VOLUME_LIMIT_EXCEEDED, writing nothing. It resets at 00:00 UTC (rate limits). Live keys are never limited by it.Waits behind live sends, and holds at most 20,000 of them. Test sends that are scheduled (
scheduled_at, or an audience blast) ride the same queue as live ones, but the send cron always takes due live messages first and shares what is left between organizations in turn, so a test blast can delay only other test sends, and not by much. An organization may have at most 20,000 test-mode messages waiting to send (scheduled, or claimed and mid-send); a scheduled send or blast that would pass that answers429 TEST_SCHEDULE_LIMIT_EXCEEDEDand schedules nothing. Wait for them to send, or cancel some (DELETE /v1/jobs/{id}stops a blast). Live keys are never limited by it.Shares both of those between tenants. Under a tenant — a tenant-bound key, or
X-Sendoka-Tenant-Ref— a request is also counted against that tenant's own share, a quarter of the organization's: 5,000 of the 20,000 waiting messages, and a quarter of the day's allowance. One tenant's runaway suite therefore cannot spend every other tenant's, and its refusals name only its own share, never the organization's totals.Shows a page, not a redirect, when a test message's tracked link is clicked. The link's destination was chosen by whoever held the test key, so instead of a
302from our domain it lands on a page that names the destination and asks for a click, noting the link came from a test message. The click is still recorded and fans outmessage.clickedwith"environment": "test". Live links from your own verified domain redirect as always; live links from the sandbox sender get the page too (events.md).Mints test unsubscribe links. A test-mode broadcast still carries a signed
{{unsubscribe_url}}andList-Unsubscribeheader, so you can check they are there, but redeeming one changes nothing: the page says the link came from a test message, and the one-clickPOSTanswers200without writing a suppression, touching a list membership or firingmessage.unsubscribed. Test the opt-out flow itself with a live send to an address you own.Sees only test-mode webhook deliveries.
GET /v1/webhooks/{id}/deliveries(with or without?include=payload), the single-delivery view and both replay routes return, or re-send, only deliveries whose payload says"environment": "test"— your test sends' events and test-fire events. A live delivery is a404to a test key. A test key's delivery list reaches back 7 days, and each page reads at most 5,000 of the endpoint's deliveries, from its cursor back: the endpoint is shared with live traffic, so a page can come back short, or empty, withhas_more: trueand anext_cursorto keep reading from.
What test mode can't catch: provider-side rejections (SES content policies, carrier filtering, DNS problems). Verify a domain and send one live message before launch.
Running CI with a test key
Set up once with a live key, then let CI send with a test key only:
- Provision the fixtures with a live key, from a setup script you run once, not from every CI run: the templates your suite sends, a webhook endpoint pointing at your CI receiver, the audience and contacts your blast tests target, and any tenants you address with
X-Sendoka-Tenant-Ref. A scoped live key is enough. Templates, audiences, pools and tenants answer409on a slug that already exists andPOST /v1/audiences/{id}/contactsupserts by address, so the script is safe to re-run; list webhook endpoints before creating one. - Give CI only the test key. Create it in the dashboard, or with a live key (
POST /v1/keyswith"environment": "test"); a test key cannot mint keys. It sends against those fixtures —POST /v1/audiences/{id}/sendwith a test key schedules test-mode rows and never reaches a provider — and reads the results back. Nothing it does can change what a live send reads, or spend a limit a live send needs: its scheduled messages wait behind live ones (at most 20,000 waiting), its rate limits are its own, and it writes at most your plan's daily allowance of test messages (1,000 a day on Free) — size fixtures and suites to fit. - Keep the fixtures away from live traffic. Contacts are shared between modes, so a fixture audience is a real list: a live blast aimed at it would mail its contacts. Use a dedicated audience (or a separate project) that no live blast targets, filled with addresses you own.
Revoking
Dashboard → delete → soft-delete via revoked_at timestamp. Revoked keys fail the isNull(revoked_at) filter in validateApiKey.
Auth failure
Missing/malformed Bearer or invalid key → 401 authentication_error:
{ "error": { "type": "authentication_error", "message": "Invalid or missing API key", "code": "UNAUTHORIZED" } }