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