Concepts & glossary
One-page vocabulary for Sendoka. Skim top-to-bottom, or ctrl-F for a term.
Core entities
Org — the billing boundary. One subscription, one set of members, one set of domains / keys / templates. User → many orgs via memberships. IDs prefixed org_.
User — a human login. Tied to zero or more orgs via memberships. IDs prefixed usr_.
Membership — a user's role inside an org: owner, admin, or member. IDs prefixed mem_. Owner-only mutations live behind requireOwnerSession.
Tenant — a sub-org inside platform mode. Used when you're building a SaaS on top of Sendoka and want your own customers isolated. Keys, domains, webhooks, templates, audiences, suppressions, usage — all carry a nullable tenantId. Platform-root keys see everything; tenant-bound keys see only their slice. See features/platforms.md.
API key — credentials for the REST API. sok_live_… or sok_test_…. Stored as sha256 hash + last-four. Carries scope, optional expiry, optional IP CIDR allowlist, optional tenant binding, optional domain allowlist. IDs prefixed key_.
Domain — a verified sending domain. Sends SES CNAMEs for DKIM + verification tokens. States: pending → verified/failed. IDs prefixed dom_.
Message — one send. Carries channel (email/sms), status, from/to, body, provider ids. IDs prefixed msg_.
Template — slug + versioned {{variable}} content. Referenced by slug on send. IDs prefixed tpl_.
Audience — a list of recipients for bulk send. IDs prefixed aud_. Contacts IDs prefixed con_.
Webhook endpoint — subscriber URL for events. Per-org, tenant-scoped in platform mode. Carries HMAC secret. IDs prefixed whk_.
Webhook delivery — one attempt to POST an event. Stored durably in webhook_deliveries, retried with exponential backoff (max 5). IDs prefixed whd_.
Suppression — an address on the do-not-send list. Reason: bounce, complaint, stop, unsubscribe, manual. Scope: org + optional tenant. Checked on every send.
States & lifecycle
Message status
queued → scheduled → sending → sent → delivered
↘ ↘ bounced
canceled failed
complained
queued— rarely persisted; internal flight state.scheduled—scheduled_atin the future. Picked up by/api/cron/send-scheduledat minute granularity.sending— transient. The scheduled-send cron stamps this viaUPDATE … FOR UPDATE SKIP LOCKEDso overlapping runs can't double-send. Rows stuck insendingfor > 5 min are re-eligible (crashed-runner recovery).sent— handed to SES/SNS successfully.delivered— provider confirmed acceptance (SES delivery notification / SNS DELIVERED).bounced— recipient rejected. Address is auto-suppressed.complained— spam complaint. Address is auto-suppressed.failed— provider error before send (validation at SES, throttled, etc.).canceled— manual cancel of ascheduledmessage viaDELETE /api/v1/emails/:id.
Domain verification
pending ─(DNS propagated)─→ verified
↘ (24h no CNAME) ↘ failed
Re-check hourly via /api/cron/check-domains. failed can be retried — it queries SES and flips to verified if records are live.
API key environment
testkeys — no provider call. Returns simulated ids. No usage counted. Good for CI and local dev.livekeys — real SES/SNS calls, real delivery, real usage counted, real money.
Tracking concepts
Open tracking — a 1×1 signed pixel inserted into html before send. Firing it records opened_at. Opt out with track_opens: false.
Click tracking — all links in html are rewritten through a signed redirect. Firing it records clicked_at. Opt out with track_clicks: false. URL signatures use HMAC (see signClickUrl).
Unsubscribe header — RFC 8058 one-click. List-Unsubscribe: <mailto:...>, <https:...> and List-Unsubscribe-Post: List-Unsubscribe=One-Click. Hitting the URL adds the address to suppressions.
Delivery concepts
Idempotency — send with Idempotency-Key: <uuid> and Sendoka stores the response for 24h. Replay returns the cached response. Body must match (hashed) — mismatch returns IDEMPOTENCY_MISMATCH 409.
Rate limits — two layers:
- Burst (per-key, sliding window via Upstash Redis) — Free 60/min, Pro 600/min, Enterprise 6000/min.
- Quota (per-org, monthly) — plan-level cap on email/SMS counts.
Headers on every response: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-RateLimit-Plan, X-RateLimit-Scope.
Warmup — new verified domains have daily caps that grow over 30 days. Exceeding the day's cap returns 429 WARMUP_LIMIT_EXCEEDED. See features/domains.md.
Fan-out — after a successful send, Sendoka inserts rows into webhook_deliveries for every matching endpoint, then calls them from next/server after(). Failures are retried by /api/cron/retry-webhooks.
Auth concepts
Session — NextAuth v4 JWT with custom claims: session_id, session_version. Server reads session via requireSession() / requireOwnerSession() — never session.user.orgId directly.
session_version — bumped on password change / 2FA disable / key rotation. In-flight JWTs with the old version are invalidated.
Scopes — each key has scopes[]: emails:send, sms:send, webhooks:read, etc. Enforced at the route level before handler runs.
2FA — TOTP secret (Speakeasy). Enrollment flow stores secret encrypted; verification uses 30-second window with replay protection.
IDs
All prefixed <type>_{nanoid(24)}:
| Prefix | Type |
|---|---|
usr_ |
User |
org_ |
Org |
mem_ |
Membership |
key_ |
API key |
dom_ |
Domain |
msg_ |
Message |
tpl_ |
Template |
aud_ |
Audience |
con_ |
Contact |
whk_ |
Webhook endpoint |
whd_ |
Webhook delivery |
usg_ |
Usage counter |
req_ |
Request (log correlation) |
Note: nanoid is not monotonic. Pagination uses compound cursor (created_at + id), see api/overview.md.
Cursor pagination
base64url(`{ISO-8601 created_at}|{id}`)
Every list endpoint returns { data, has_more, next_cursor }. Pass ?cursor=<value>&limit=<1..100>.
Both created_at and id appear in the WHERE clause — otherwise ties break wrong and pages overlap.
Platform mode (multi-tenant)
Designed for SaaS apps where your customers are the end-users of Sendoka.
- Platform-root key — org-wide. Can create tenants, send on behalf of any tenant, read across tenants.
- Tenant-bound key — scoped to one tenant. Sees only that tenant's domains, suppressions, webhooks, messages, templates, audiences, usage.
- Shared infra — one SES account, one billing account, one domain-verification pool.
- Isolated data — suppressions, usage, templates, webhook endpoints never leak between tenants.
See features/platforms.md for setup and features/platforms-walkthrough.md for a first-tenant run-through.
Deliverability hygiene
DKIM — domain-owner signing key. SES manages rotation. CNAMEs set during verification.
SPF — envelope-sender policy. Add include:amazonses.com to your TXT.
DMARC — enforces DKIM+SPF alignment. Start with p=none reporting, move to p=quarantine / p=reject.
BIMI — brand logo in Gmail tabs. Requires DMARC p=quarantine+, VMC cert. Outside Sendoka — set it on your DNS.
Bounce rate — SES pauses sending >5%. Sendoka's warmup + suppression handling is designed to keep you well under.
Complaint rate — >0.1% and SES flags you. Make unsubscribe obvious.
Terms you'll see in logs
safeFetch — our outbound fetch wrapper. DNS-resolves the target, blocks private IP ranges, enforces timeout, rejects redirects. Used for webhook delivery to prevent SSRF.
requireSession / requireOwnerSession — server helpers returning { userId, email, orgId, defaultOrgId, role } for the active org (not the sign-in default).
fanoutWebhookEvent — the single entry point for emitting events. Never bypass it.
incrementUsage(orgId, channel, env, by, keyId?, tenantId?) — batch-aware usage counter. Call once per send with by: count.
logAudit — writes to audit_log for security-sensitive actions. Internally logErrors on DB failure so an audit-row drop doesn't silently succeed.