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 deleted 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.
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). |
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://app.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: tnt_…andX-Sendoka-Tenant-Created: true. - If found but suspended/archived →
409 TENANT_NOT_USABLE. Auto-resurrect is intentionally not allowed — call/unsuspendfirst.
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. - 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. - 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.
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 three actions emit audit log entries: tenant.suspended, tenant.unsuspended, tenant.quota_updated.
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": "tnt_..."}'
# 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": "tnt_...", "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).