Platforms: building multi-tenant on Sendoka
Sendoka supports a platform model where one Sendoka organization hosts many downstream tenants — typically used when you're building a SaaS that resells email / SMS to your own customers.
This pass ships the data-model and API primitives. A few pieces remain infrastructure-level — see Deferred at the bottom.
Mental model
Platform org ← your Sendoka account
├─ tenant: acme ← your customer "Acme"
│ ├─ api keys bound to acme
│ ├─ domains owned by acme (optional)
│ ├─ suppression list scoped to acme
│ ├─ webhooks for acme
│ └─ templates / audiences / messages
├─ tenant: globex
│ └─ …
└─ org-level resources (platform-wide defaults)
Tenants CRUD
Public API, scope write:tenants / read:tenants.
POST /api/v1/tenants
{ "name": "Acme Corp", "slug": "acme", "external_ref": "customer_12345" }
external_ref stores your own primary key so you can look up by your id without an extra database.
GET /api/v1/tenants
GET /api/v1/tenants/:id
GET /api/v1/tenants/:id/usage?period=2026-04
DELETE /api/v1/tenants/:id
Soft-delete: status flips to archived (there is no deleted status) but underlying messages / audit rows are preserved.
Binding API keys to a tenant
Pass tenant_id to POST /v1/keys (see Provisioning a tenant entirely via API), or pick a Tenant in the dashboard's New key dialog. Every request under a tenant-bound key:
- Stamps
ctx.tenantIdinto writes (messages,suppressions,audiences, etc.). - Filters reads to that tenant.
- Can't see sibling-tenant suppressions, messages, webhooks, templates.
- Cannot create new tenants (rejected with
TENANT_KEY_CANNOT_CREATE_TENANTS). - Cannot create, rename or delete projects (
POST /v1/projects,PATCH/DELETE /v1/projects/:id→403 TENANT_KEY_FORBIDDEN). Projects are the platform's structure; a tenant key can still read its own.
Additionally, api_keys.allowed_domain_ids[] restricts which domains a key may send from — useful when a tenant has a single owned domain and you don't want the key broadening.
Scopes
Pass scopes on key creation to mint a least-privilege key. Omit the field for a
full-access key (scopes stays null, which every hasScope check treats as
unrestricted); an empty array is rejected, since it would mint a key that can do
nothing. Valid values are the SCOPES list in src/lib/api/scopes.ts.
POST /api/v1/keys
{ "name": "Acme webhook reader", "environment": "live",
"scopes": ["read:messages", "read:events"] }
A scoped key may only mint keys whose scopes are a subset of its own, and can
never mint a full-access key — both return 403 SCOPE_ESCALATION. A key with
scopes: null may grant anything. write:keys is additionally never grantable
through OAuth consent, so an access token can't bootstrap further credentials.
The same containment applies to the other two allowlists a key can carry. A key
with a non-empty allowed_domain_ids may only mint keys whose
allowed_domain_ids is a subset of its own, and may not mint an unrestricted
key (403 DOMAIN_SCOPE_ESCALATION); allowed_cidrs behaves identically
(403 CIDR_SCOPE_ESCALATION). Without this, a key pinned to one domain or one
source network could launder the restriction away in a single call. Note the
empty-list convention differs from scopes: for these two fields null and
[] both mean unrestricted, whereas an empty scopes array grants nothing —
see src/lib/api/key-restrictions.ts.
What's tenant-isolated
| Surface | Isolation |
|---|---|
suppressions |
Tenant A's unsubscribe does not block Tenant B. Platform-wide rows (null tenantId) still block everyone. |
webhook_endpoints |
Endpoints with tenantId = X only receive X's events. Null tenantId = receives all (platform firehose). The plan's endpoint cap applies per tenant: each tenant gets its own copy (counting only its endpoints), and org-level endpoints share a separate one — so one endpoint per tenant never runs into the cap. See webhooks → endpoint cap. |
domains |
tenantId marks ownership. Tenant-bound keys can only use matching-tenant or null-tenant domains. |
messages |
Writes stamp tenantId; queries filter by it. |
templates |
Slug is unique per tenant — welcome can exist separately per tenant. |
audiences + contacts |
Same — slug unique per tenant; contact dedup per tenant. |
tenant_usage |
Per-tenant monthly counters you can roll up for reseller billing. |
| Test-mode volume | A tenant's test traffic (tenant-bound key or X-Sendoka-Tenant-Ref) draws on its own share — a quarter of the organization's daily test allowance and of the 20,000 test messages it may have waiting — so one tenant's suite cannot spend every other's (rate limits). |
Projects and tenants
A tenant is one of your customers — a merchant, a workspace, a shop — and is stamped with the project it was created in. A project separates whole workloads inside your Sendoka account: its own API keys, tenants, sending domains and messaging pools. Use a tenant per customer of your product; reach for a separate project only when you need a second, fenced-off set of keys and tenants, such as a second app. Every account starts with a default project, and the rest of this section only matters once you add another.
A key bound to a project other than the organization's default one reaches
only its own project's tenants, API keys, sending domains and messaging pools,
plus the default project's and any with no project: GET/PATCH/DELETE /v1/keys, every /v1/tenants/{id} route and the SMS from_pool lookup answer
as if another project's row did not exist, and an X-Sendoka-Tenant-Ref naming
another project's tenant answers 403 TENANT_OTHER_PROJECT (refs are unique
per organization, so it is not created either). The email sender check refuses
another project's domain with 403 SENDER_DOMAIN_NOT_VERIFIED, saying the
domain belongs to another project when that is the only reason — the key's
tenant binding and allowed_domain_ids would let it use the domain's row;
otherwise it gives the usual message. A tenant_id in a create body — domains,
brands, phone numbers and sender IDs, templates, campaigns, messaging pools,
webhooks and API keys — must be a tenant the key can reach (422 otherwise),
and a pool may only be built from numbers the key can reach. A key with no
project, or in the default project, sees the whole organization as before, and
a tenant resolved from X-Sendoka-Tenant-Ref is judged once, when it is
resolved.
Usage attribution
Every successful send increments three counters in parallel:
usage_records(org-level, billing source of truth)api_key_usage(per-key, for internal attribution)tenant_usage(per-tenant, for reseller billing)
Read per-tenant via GET /api/v1/tenants/:id/usage or roll up across your platform via the dashboard /overview/tenants page.
Dashboard
/overview/tenants lists every tenant with this period's live email + SMS
counts. Owners can create new tenants inline. Each tenant has its own page
with its ten_… id, ref, status and suspension reason, its caps against live
usage, its test usage, and how many domains, keys and webhook endpoints are
bound to it, plus a link to its messages. Owners can archive an active tenant
and reactivate a suspended one there; suspend, restore, erase and caps are
API-only.
The Email and SMS logs filter by tenant, show a tenant column once the page holds tenant traffic, and match a search against the tenant's ref or slug.
Auto-provisioning your own customers
Onboarding a customer is three platform-root calls — create the tenant, add
its domain, and (only if the customer needs credentials of its own) mint it a
tenant-bound key. All three are public API calls; no dashboard session is
needed. See Provisioning a tenant entirely via API,
or skip the explicit create entirely with X-Sendoka-Tenant-Ref below.
Auto-provisioning tenants
For platforms that mint Sendoka resources lazily — first time you send for a customer, no tenant exists yet — pass X-Sendoka-Tenant-Ref instead of looking up / creating the tenant manually.
POST /api/v1/emails
Authorization: Bearer sok_live_<platform-root key>
X-Sendoka-Tenant-Ref: cust_12345 ← your stable customer id
{ "from": "noreply@verified-domain.com", "to": ["…"], … }
What happens behind the scenes:
- Lookup — find a tenant under your org with
external_ref = "cust_12345". - If found and active — stamp the request with that tenant's id; rest of the call (suppressions, usage, webhooks, audit) scopes to it as if you'd passed
tenant_idexplicitly. - If not found — create a tenant with
name = "cust_12345",slug = slugify("cust_12345"),external_ref = "cust_12345". The created tenant id comes back in the response:X-Sendoka-Tenant-Id: ten_…andX-Sendoka-Tenant-Created: true. - If found but suspended/archived →
409 TENANT_NOT_USABLE. Auto-resurrect is intentionally not allowed: call/unsuspendfor a suspended tenant, or/restorefor an archived one (a returning customer).
Guardrails
- Platform-root keys only. Tenant-bound keys passing this header get
403 TENANT_KEY_FORBIDDEN. Otherwise a leaked tenant key could spawn neighbours. - Race-safe. Two concurrent requests with the same
external_refresolve to the same tenant — the second one sees the unique-constraint violation and re-selects the winner. - Case-insensitive. The ref is folded to lower case before it is looked up or stored, so
Shop.myshopify.comandshop.myshopify.comare one tenant. The tenant'snamekeeps the casing you sent; only the lookup key is folded.GET /v1/tenants?external_ref=folds the same way, so the duplicate-check you run before creating a tenant matches what auto-provisioning stored. - Slug collision-safe. If
slugify(ref)is taken by an unrelated tenant, a 6-char suffix is appended. - Rate-limited. 60 auto-creates per minute per org — typo loops get 429
TENANT_AUTO_CREATE_RATE_LIMITEDinstead of spawning thousands of slots. Only an actual create counts against this budget: requests that resolve to an existing tenant (whatever its status) are never charged, so steady traffic over known refs is not throttled by it. - Audit trail. Each auto-created tenant gets a
tenant.createdaudit log entry withmetadata.auto = trueand the originating ref. - Live keys only create. With a
sok_test_*key a ref that matches an existing, active tenant resolves exactly as above, so test sends on a tenant's behalf work. A ref that matches no tenant answers403 ENVIRONMENT_ESCALATIONinstead of creating one: tenants have no test-mode copy, so a tenant a test key created would be a live one, holding a slot of your plan's tenant cap and the ref your live integration will send. Nothing is created and no auto-create budget is spent. Create the tenant with a live key first (POST /v1/tenants, or any live request carrying the header). - Creating needs
write:tenants. Resolving an existing tenant needs only the route's own scope, but a ref that matches no tenant creates one only for a key that holdswrite:tenants— the scopePOST /v1/tenantsasks for. A scoped key without it (a send-only key, or an OAuth token granted to a developer) gets403 INSUFFICIENT_SCOPEinstead, before any auto-create budget or plan-cap check, and nothing is created. Full-access keys (no scope list) are unaffected. Without this, any narrowly scoped key could mint tenants on every route, squatting refs and filling your plan's tenant cap.
What auto-provisioning does NOT do
- It does not create or verify a sender domain. Domain verification requires DKIM DNS records and an explicit step — see Domains.
- It does not mint a tenant-bound API key. Keep using your platform-root key with the header, or call
POST /v1/keysif you need a separate tenant-scoped key (e.g. for a customer's own integration). - It does not set quotas. Caps default to null (unlimited within the org plan). Set them later via
PATCH /v1/tenants/:id/quota.
Reading one tenant's traffic
The header scopes reads too. GET /v1/emails, GET /v1/sms and their by-id
routes, sent with X-Sendoka-Tenant-Ref, return only that tenant's messages —
the same slice a key bound to the tenant sees. The list routes take no
tenant_id query parameter: without the header (or a tenant-bound key) a
platform-root key lists every tenant's messages. Each message object the list
and by-id reads return carries tenant_id (null for a send made with no
tenant).
A live read carrying a ref that matches no tenant creates the tenant, like any other live request with the header.
Tenant lifecycle
Three states: active (default) · suspended (blocked) · archived (soft-deleted).
Every lifecycle write needs a live key. A tenant serves live and test traffic
alike, so creating, suspending, unsuspending, archiving, restoring, erasing a
tenant and setting its quota all answer 403 ENVIRONMENT_ESCALATION to a
sok_test_* key; reads (GET /v1/tenants, /usage) stay open to it.
Suspend a tenant
POST /api/v1/tenants/:id/suspend
{ "reason": "Non-payment" }
Effect is immediate:
- Every tenant-bound API key returns
403 TENANT_SUSPENDEDon the next request (auth checked inwithApiAuthafter scope check). - Sends referencing the tenant via a tenant-bound key are blocked at the auth layer.
- Existing data (messages, suppressions, usage) stays queryable for support and chargeback.
Unsuspend
POST /api/v1/tenants/:id/unsuspend
Returns 409 TENANT_NOT_SUSPENDED if the tenant isn't currently suspended.
Owners can also reactivate a suspended tenant from the dashboard tenants page
(PATCH /api/internal/tenants?id=… { "action": "unsuspend" } behind the scenes).
Archive (soft-delete)
DELETE /api/v1/tenants/:id
Only active tenants can be archived — a suspended tenant returns
409 TENANT_NOT_ACTIVE and must be unsuspended first. Archiving an
already-archived tenant is idempotent: it answers 200, so a retried
uninstall webhook is not an error. Archiving cancels the tenant's
still-scheduled messages (same eager cancel as suspension) and keeps all
existing data (messages, suppressions, usage) queryable.
An archived tenant keeps its external_ref — the column is unique per org and
the platform-mode resolver will not auto-resurrect — so a customer who comes
back is re-provisioned by restoring the row, not by creating a second one:
POST /api/v1/tenants/:id/restore
archived → active, idempotent (an already-active tenant answers 200 with
restored: false), and refuses a suspended tenant, which was stopped
deliberately and goes through /unsuspend instead. Restore rather than
delete-and-recreate on purpose: the archived tenant still holds that customer's
suppression list, and a returning merchant must not start mailing the people
who opted out before they left.
Erase a tenant's personal data (GDPR)
Archiving keeps history queryable — which is exactly wrong once the customer
behind the tenant demands erasure (Shopify's shop/redact webhook, an
Article 17 request). For that:
POST /api/v1/tenants/:id/erase
No request body. Per table, irreversibly:
messages— content is redacted in place: recipient, bodies, cc/bcc, headers, error text, the raw provider response, attachment metadata, media URLs and metadata are nulled, but the rows stay — status, channel, segments and timestamps keep feeding analytics, usage and billing. Any row stillqueued/scheduled/sendingis flipped tocanceledin the same statement (a redacted row has no destination left). The provider response matters as much as the recipient column: it is the raw AWS bounce/complaint/delivery object, it embeds the address, andGET /v1/emails/:idreturns it verbatim.- Status history — the transitions stay (they belong to rows that stay),
but their free-text
detailis nulled: a bounce detail carries the SMTP diagnostic, which routinely quotes the recipient's address. message_events— deleted (each row is an end user's raw IP, user-agent and clicked URL).- Contacts — deleted, audience memberships included.
- Inbound email + SMS — deleted (third-party message bodies).
- Suppressions — deliberately untouched. An opt-out is a legal obligation to remember, not personal data held for convenience; erasing it would let a re-imported contact be mailed again.
Work per call is bounded (up to 5 000 rows per table per call, the same
batching discipline as the retention cron), so a big tenant needs several
calls: repeat until done: true. Each response carries that call's
per-table counts —
{
"id": "ten_…",
"done": false,
"messages_redacted": 5000,
"status_events_redacted": 812,
"message_events_deleted": 5000,
"contacts_deleted": 214,
"inbound_deleted": 12
}
— and the totals for the whole erasure are the summed responses (a count comes
back null if that one statement failed; the next call retries it). The call
that completes the final batch fans out the
tenant.erased webhook event (counts only, never the
erased content) and writes a tenant.erased audit row. That audit row doubles
as the fires-once ledger: re-running erase on an already-erased tenant is
idempotent — done: true, zero counts, no second event.
If that ledger write itself fails, the response is done: false even though
the data is already gone: this audit row is the only evidence the erasure
happened, so the call asks to be repeated rather than reporting a completed
erasure that recorded nothing. A repeat in that state re-fires tenant.erased
(there is no ledger row to dedup against) — the same at-least-once behaviour
two concurrent completing calls produce.
Only an archived or suspended tenant can be erased. An active tenant
answers 409 TENANT_ACTIVE: erasure is irreversible and hitting a live tenant
with it is almost certainly a caller bug (a mis-wired uninstall hook, a
transposed id), so archive (DELETE /v1/tenants/:id) or suspend first.
Tenant-bound keys are refused (403 TENANT_KEY_FORBIDDEN) — a leaked embedded
key must not be able to destroy its own tenant's history.
Per-tenant monthly caps
Caps run on top of the org plan limit. A Pro plan org can still rate-limit a single tenant at 5 000 emails/month — useful when the tenant is on a free tier of your product.
PATCH /api/v1/tenants/:id/quota
{ "monthly_email_cap": 5000, "monthly_sms_cap": 100 }
Pass null to clear a cap. At least one field is required.
When a tenant hits its cap mid-month, sends return 429 TENANT_QUOTA_EXCEEDED until the period rolls over (calendar month). Caps are absolute — no overage applied even on Pro plans, since the cap exists precisely to bound a single tenant's blast radius on the platform's bill.
All lifecycle actions emit audit log entries: tenant.suspended, tenant.unsuspended, tenant.archived, tenant.quota_updated, tenant.erased.
Provisioning a tenant entirely via API
A platform-root key (sok_live_* with no tenant binding) can run the full
tenant lifecycle without a session cookie — no dashboard needed:
# 1. Create the tenant (or let X-Sendoka-Tenant-Ref auto-create on first send)
curl -X POST .../api/v1/tenants \
-H "Authorization: Bearer $PLATFORM_KEY" \
-d '{"name": "Acme", "slug": "acme", "external_ref": "cus_123"}'
# 2. Add the tenant's sending domain (returns DKIM records to hand them)
curl -X POST .../api/v1/domains \
-H "Authorization: Bearer $PLATFORM_KEY" \
-d '{"domain": "mail.acme.com", "tenant_id": "ten_..."}'
# 3. Mint a tenant-bound API key to embed in their workspace
curl -X POST .../api/v1/keys \
-H "Authorization: Bearer $PLATFORM_KEY" \
-d '{"name": "Acme prod", "environment": "live", "tenant_id": "ten_...", "allowed_domain_ids": ["dom_..."]}'
The minted key auto-stamps tenant_id on every send and can't read, send,
or configure outside its tenant. tenant_id on key creation is validated
against the org's tenants (422 on unknown ids). Tenant-bound keys cannot
create keys (platformOnly guard) — key minting stays a platform-root
privilege.
Deferred (require infra, not code)
- White-label tracking + unsubscribe domain per tenant — needs CNAME + rewrite plumbing so
track.acme.comroutes to our endpoint with the right tenant context. Data model supports it (domain.tenantId); the rewrite layer doesn't yet. - Reseller Stripe billing —
tenant_usagegives the numbers; wiring a per-tenant metered Stripe product is a Stripe product-config exercise. - Per-tenant data residency — domain-region lets you pin one region per domain. True per-tenant EU-only residency needs a region-scoped DB cluster, which is infra.
Known invariants
- Tenant-bound keys can never create tenants (
write:tenantsstill requires platform-root). - Suppression rows with
tenantId = nullapply to every send under the org. Tenant-scoped rows apply only to that tenant. - Audit logs remain org-scoped (no per-tenant audit today — roll into it if needed).