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. Fetch the message by id if your handler needs to distinguish test from live.
  • 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. 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.

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.

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" } }