Platform mode walkthrough — your first tenant end-to-end

You're building a SaaS that sells email/SMS to your customers. This walk-through takes one customer ("Acme") from zero to delivering their first email.

If you haven't, skim platforms.md first for the mental model. This doc is the hands-on version.

0. What you're building

   Your SaaS UI                          Your infrastructure
   ────────────                          ───────────────────
   Acme signs up                         Your server:
         │                                 ├─ store customer in your DB
         ▼                                 ├─ POST /api/v1/tenants
   "Please verify                          │     (using platform-root key)
    send@acme.com"                         ├─ store tenant_id
         │                                 │
         ▼                                 ├─ POST /api/v1/domains
   Acme pastes                              │     (using platform-root key,
   DNS records                              │      tenant_id=tnt_acme)
                                            │
                                            └─ POST /api/v1/keys
                                                  (mint tenant-bound key)
         │                                        return to Acme's UI
         ▼
   Acme's app uses
   sok_live_... key
   to send

Key idea: you hold the platform-root key (never exposed). Acme only ever sees a tenant-bound key — it can send, read its own messages, but can't see siblings.

1. Grab a platform-root key

Dashboard → API Keys → New key → untick "Bind to tenant". Copy.

export SENDOKA_PLATFORM_KEY=sok_live_...  # your ops secret

This key stays on your server. Never give it to an Acme employee.

2. Create the tenant

curl -X POST https://api.sendoka.com/api/v1/tenants \
  -H "Authorization: Bearer $SENDOKA_PLATFORM_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Acme Corp",
    "slug": "acme",
    "external_ref": "cust_12345"
  }'

Response:

{
  "id": "tnt_01HN...",
  "name": "Acme Corp",
  "slug": "acme",
  "external_ref": "cust_12345",
  "status": "active",
  "created_at": "..."
}

Store tnt_01HN... in your own DB keyed by cust_12345. You'll reference it constantly.

The slug is unique per org (human-readable). external_ref is your opaque pointer back to your DB row — Sendoka treats it as a string.

3. Acme wants to send from send@acme.com

3a. Create the domain (scoped to Acme)

curl -X POST https://api.sendoka.com/api/v1/domains \
  -H "Authorization: Bearer $SENDOKA_PLATFORM_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "acme.com",
    "tenant_id": "tnt_01HN..."
  }'

Response includes DKIM CNAMEs + verification token.

{
  "id": "dom_01HN...",
  "name": "acme.com",
  "tenant_id": "tnt_01HN...",
  "status": "pending",
  "dns_records": [
    { "type": "CNAME", "name": "abc._domainkey.acme.com", "value": "abc.dkim.amazonses.com" },
    { "type": "CNAME", "name": "def._domainkey.acme.com", "value": "def.dkim.amazonses.com" },
    { "type": "TXT",   "name": "_amazonses.acme.com",     "value": "randomtoken..." }
  ]
}

Show these records in your Acme-facing UI so they can add them to their DNS.

3b. Watch for verification

Sendoka checks hourly via /api/cron/check-domains, or call immediately:

curl -X POST https://api.sendoka.com/api/v1/domains/dom_01HN.../verify \
  -H "Authorization: Bearer $SENDOKA_PLATFORM_KEY"

Status flips pending → verified once SES resolves the CNAMEs. Also fires a domain.verified webhook if you've subscribed to it.

3c. Or skip DNS — offer the sandbox

If Acme just wants to try it, give them a key scoped to the shared sandbox domain (sandbox.sendoka.com). No DNS, no verification. Rate-limited, only reaches verified test inboxes — good enough for their signup-flow demo.

4. Mint Acme's API key (tenant-bound)

curl -X POST https://api.sendoka.com/api/v1/keys \
  -H "Authorization: Bearer $SENDOKA_PLATFORM_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Acme primary",
    "tenant_id": "tnt_01HN...",
    "environment": "live",
    "scopes": ["emails:send", "sms:send", "messages:read"],
    "allowed_domain_ids": ["dom_01HN..."]
  }'

Response:

{
  "id": "key_01HN...",
  "secret": "sok_live_abc123...",   // shown ONCE — forward to Acme
  "tenant_id": "tnt_01HN...",
  "scopes": ["emails:send", "sms:send", "messages:read"]
}

Forward secret to Acme over a secure channel — in-app banner, initial onboarding email, etc. Sendoka stores only sha256(secret) + last_four so you can never show it again.

allowed_domain_ids scopes this key to acme.com only. If Acme later adds a second domain, update the key.

5. Acme sends — as far as Acme is concerned it's just the API

From Acme's app:

await fetch("https://api.sendoka.com/api/v1/emails", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SENDOKA_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    from: "welcome@acme.com",
    to: ["new-customer@example.com"],
    subject: "Welcome to Acme",
    html: "<p>...</p>",
  }),
});

Behind the scenes:

  • Auth: Sendoka resolves the key → {orgId = YOU, tenantId = tnt_01HN...}.
  • Domain check: welcome@acme.comdom_01HN... which belongs to tnt_01HN... ✓.
  • Suppression filter: applies acme + org-wide suppressions only.
  • Insert: messages row with tenant_id = tnt_01HN....
  • Usage: incrementUsage(orgId, "email", "live", 1, keyId, tenantId) — counted against your billing, trackable per-tenant.
  • Webhook fan-out: selects endpoints with tenant_id = null OR tenant_id = tnt_01HN....

If Acme tries to send from foo@not-acme.com, they get:

{
  "error": {
    "type": "authentication_error",
    "code": "DOMAIN_NOT_ALLOWED_FOR_KEY",
    "message": "from acme.com is not in key's allowed_domain_ids"
  }
}

6. Set up Acme's webhooks (optional, their choice)

Acme wants events for their own dashboard. They create a webhook through their own UI; your server mints it for them:

curl -X POST https://api.sendoka.com/api/v1/webhooks/endpoints \
  -H "Authorization: Bearer $SENDOKA_PLATFORM_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://acme.com/api/sendoka-webhook",
    "tenant_id": "tnt_01HN...",
    "events": ["message.delivered", "message.bounced"]
  }'

Response includes secret — forward to Acme, they use it to verify signatures. See recipes/verify-webhooks.md.

Now Acme gets events for their sends only. Globex's bounces don't leak to Acme's endpoint.

7. Show Acme their usage

Per-tenant usage API:

curl "https://api.sendoka.com/api/v1/tenants/tnt_01HN.../usage?period=2026-04" \
  -H "Authorization: Bearer $SENDOKA_PLATFORM_KEY"
{
  "tenant_id": "tnt_01HN...",
  "period": "2026-04",
  "email_live": 18200,
  "email_test": 34,
  "sms_live": 112,
  "sms_test": 0
}

Use this to drive Acme's "messages this month" counter in your UI, or to bill them.

8. Suspend Acme (suspension, not deletion)

curl -X PATCH https://api.sendoka.com/api/v1/tenants/tnt_01HN... \
  -H "Authorization: Bearer $SENDOKA_PLATFORM_KEY" \
  -d '{ "status": "suspended" }'

All Acme-bound keys start returning 403 TENANT_FORBIDDEN. Their sends stop; their historical data is preserved. Flip back to active to resume.

9. Delete Acme (danger)

curl -X DELETE https://api.sendoka.com/api/v1/tenants/tnt_01HN... \
  -H "Authorization: Bearer $SENDOKA_PLATFORM_KEY"

Cascade deletes: api_keys, domains, webhook_endpoints, messages, suppressions, templates, audiences, contacts, usage_counters — all rows with tenant_id = tnt_01HN....

This is irreversible. Wire a soft-delete in your own UI if your contract with Acme requires data export first.

Recap — what you learned

  1. One platform-root key lives on your server, creates everything.
  2. Each of your customers is a tenant. Store tenant_id keyed by your own customer id.
  3. Mint one tenant-bound key per tenant. Scope it to their domains.
  4. Hand it to them; they send as if they had their own Sendoka account.
  5. Webhooks, suppressions, usage are all tenant-scoped automatically.
  6. You see everything; they see only themselves.

Common traps

  • Don't let tenants hold the platform-root key. They'd see everything.
  • Don't share domains across tenants. SES DKIM is per-domain; SPF alignment breaks if tenants share.
  • Tenant usage is tracked separately but billed to you. Your subscription with Sendoka is org-wide; charge your tenants however you like, but the bill comes to you.
  • Cascade delete is immediate. Export first if you need audit.
  • Verify Acme's webhook secret once; store it in Acme's env. Don't proxy their webhook through your own server — Sendoka signs with the endpoint's secret, and Acme needs that secret to verify.

Next