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.tenantId into 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:

  1. usage_records (org-level, billing source of truth)
  2. api_key_usage (per-key, for internal attribution)
  3. 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:

  1. Lookup — find a tenant under your org with external_ref = "cust_12345".
  2. 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_id explicitly.
  3. 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_… and X-Sendoka-Tenant-Created: true.
  4. If found but suspended/archived409 TENANT_NOT_USABLE. Auto-resurrect is intentionally not allowed: call /unsuspend for a suspended tenant, or /restore for 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_ref resolve 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.com and shop.myshopify.com are one tenant. The tenant's name keeps 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_LIMITED instead 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.created audit log entry with metadata.auto = true and 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/keys if 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_SUSPENDED on the next request (auth checked in withApiAuth after 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 still queued/scheduled/sending is flipped to canceled in 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, and GET /v1/emails/:id returns it verbatim.
  • Status history — the transitions stay (they belong to rows that stay), but their free-text detail is 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)

  1. White-label tracking + unsubscribe domain per tenant — needs CNAME + rewrite plumbing so track.acme.com routes to our endpoint with the right tenant context. Data model supports it (domain.tenantId); the rewrite layer doesn't yet.
  2. Reseller Stripe billingtenant_usage gives the numbers; wiring a per-tenant metered Stripe product is a Stripe product-config exercise.
  3. 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:tenants still requires platform-root).
  • Suppression rows with tenantId = null apply 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).