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.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.

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:

  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://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:

  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: tnt_… and X-Sendoka-Tenant-Created: true.
  4. If found but suspended/archived409 TENANT_NOT_USABLE. Auto-resurrect is intentionally not allowed — call /unsuspend first.

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.
  • 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.
  • 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.

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)

  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).