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