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():

  1. Rejects keys without a sok_live_ / sok_test_ prefix. Legacy mr_live_ / mr_test_ keys minted before the rebrand are still accepted and behave identically; new keys are always issued sok_.
  2. Computes sha256 of incoming key, looks up api_keys where key_hash matches AND revoked_at IS NULL.
  3. Updates last_used_at fire-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_response is { "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.sent etc.) 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 and X-Sendoka-Environment: test on the request, for the send's own events and for the ones that follow it — opens, clicks, and a test blast's job.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/campaigns and /v1/phone-numbers with a test key never reach The Campaign Registry or AWS: no billed CreateRegistration, no leased number, nothing orphaned at the provider when your test tears the row down. The row comes back immediately as status: "verified" with a test-reg-… provider id, an empty provider_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 answering 409 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/usage with 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 answers 403 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-Ref that matches no tenant is refused rather than auto-created (a live key holding write: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, and POST /v1/audiences/{id}/send to 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 usual 429 for 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 answers 429 TEST_SCHEDULE_LIMIT_EXCEEDED and 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 302 from 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 out message.clicked with "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}} and List-Unsubscribe header, 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-click POST answers 200 without writing a suppression, touching a list membership or firing message.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 a 404 to 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, with has_more: true and a next_cursor to 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:

  1. 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 answer 409 on a slug that already exists and POST /v1/audiences/{id}/contacts upserts by address, so the script is safe to re-run; list webhook endpoints before creating one.
  2. Give CI only the test key. Create it in the dashboard, or with a live key (POST /v1/keys with "environment": "test"); a test key cannot mint keys. It sends against those fixtures — POST /v1/audiences/{id}/send with 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.
  3. 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" } }