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: pendingverified/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.
  • scheduledscheduled_at in the future. Picked up by /api/cron/send-scheduled at minute granularity.
  • sending — transient. The scheduled-send cron stamps this via UPDATE … FOR UPDATE SKIP LOCKED so overlapping runs can't double-send. Rows stuck in sending for > 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 a scheduled message via DELETE /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

  • test keys — no provider call. Returns simulated ids. No usage counted. Good for CI and local dev.
  • live keys — 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:

  1. Burst (per-key, sliding window via Upstash Redis) — Free 60/min, Pro 600/min, Enterprise 6000/min.
  2. 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.