Public API Overview

Base URL: https://<host>/api/v1

All endpoints require a Bearer API key.

Authorization: Bearer sok_live_...   # or sok_test_...

Content type: application/json.

Endpoints

The send + read surface, which every integration starts from:

Method Path Purpose
POST /api/v1/emails Send one email
POST /api/v1/emails/batch Send up to 100 emails
GET /api/v1/emails List emails (cursor pagination)
GET /api/v1/emails/{id} Retrieve one email
POST /api/v1/sms Send one SMS
POST /api/v1/sms/batch Send up to 100 SMS
GET /api/v1/sms List SMS
GET /api/v1/sms/{id} Retrieve one SMS

The full surface — audiences, contacts, templates, domains, webhooks, tenants, projects, keys, SMS registration, verifications, inbound, jobs, usage — is listed in /api/openapi.json, which is the authoritative contract.

Cross-cutting behavior

Every request passes through withApiAuth (src/lib/api/middleware.ts):

  1. Auth — validates the Bearer token against the api_keys table (sha256 hash), then checks the key's scope, expiry, IP allowlist and the org's suspension status.
  2. Rate limit — Upstash sliding 60-second windows, evaluated by src/lib/api/rate-limit.ts. A per-plan bucket per orgfree 60 / min, payg 600, pro 600, enterprise 6000 — and, only when the key has rate_limit_per_minute set, a per-key bucket checked first. Either one rejecting is 429 RATE_LIMITED. Skipped (requests pass, no X-RateLimit-* headers) when the Upstash env vars are absent. Full detail: rate-limits.md.
  3. Context — the handler receives { orgId, apiKeyId, environment, tenantId, projectId, scopes, ... }.

The nine Idempotency-Key-accepting POSTs (/emails, /sms, /emails/batch, /sms/batch, /phone-numbers, /brands, /campaigns, /verifications, /audiences/{id}/send) additionally:

  1. Idempotency — the header replays the stored response for 24h (IDEMPOTENCY_TTL_HOURS, tunable to 168). Same key + different body is 409 IDEMPOTENCY_MISMATCH. See idempotency.md.

Send endpoints additionally:

  1. Usage limit — free-tier caps are enforced in the live environment (429 USAGE_LIMIT_EXCEEDED); metered tiers bill overage but stop at the org's spend cap (402 SPEND_CAP_EXCEEDED); the test environment never reaches a provider and is metered separately.

Response envelopes

Single resource:

{ "id": "msg_...", "channel": "email", "status": "sent", "created_at": "..." }

List:

{ "data": [...], "has_more": true, "next_cursor": "MjAyNi0wNC0yMlQxMDoxNDoyMi4xMTNafG1zZ18wMUhO..." }

next_cursor is an opaque compound key — base64url("{created_at ISO}|{id}"), minted by src/lib/api/cursor.ts — not a message id. Pass it back verbatim as ?cursor=; it is null when has_more is false. A value that does not decode is 422 INVALID_CURSOR, never silently page one.

Batch (/emails/batch, /sms/batch):

{
  "data": [
    { "index": 0, "id": "msg_...", "status": "sent",   "error": null },
    { "index": 1, "id": null,      "status": "failed", "error": { "code": "SUPPRESSED", "message": "..." } }
  ],
  "total": 2,
  "succeeded": 1,
  "failed": 1
}

The counter is succeeded, not successful. id is null for an item that never produced a message row — see emails.md for the four id / status combinations.

Error: see errors.md.

See also: authentication.md, emails.md, sms.md, rate-limits.md, idempotency.md.