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
Set tenantId on api_keys (internal for now — dashboard create flow will expose this soon). 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).
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. |
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 current-period email + SMS counts. Owners can create new tenants inline.
Auto-provisioning your own customers
Typical flow for a CRM platform onboarding its 500th customer:
const mr = new Sendoka({ apiKey: process.env.SENDOKA_PLATFORM_KEY });
const tenant = await mr.tenants.create({
name: customer.name,
slug: customer.slug,
external_ref: customer.id,
});
const key = await fetch("https://www.sendoka.com/api/internal/api-keys", {
method: "POST",
// …with platform session or future admin token
body: JSON.stringify({
name: `${customer.slug} key`,
environment: "live",
tenantId: tenant.id,
allowedDomainIds: [customerDomainId],
}),
});
// Your customer now uses `key.key` for their sends.
// Every send, suppression, webhook is isolated.
A platform admin token for issuing tenant-bound keys programmatically is the missing piece; today it requires a session cookie.
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.
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.
Tenant lifecycle
Three states: active (default) · suspended (blocked) · archived (soft-deleted).
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).