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=ten_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

Overview → 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://www.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": "ten_01HN...",
  "name": "Acme Corp",
  "slug": "acme",
  "external_ref": "cust_12345",
  "status": "active",
  "created_at": "..."
}

Store ten_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://www.sendoka.com/api/v1/domains \
  -H "Authorization: Bearer $SENDOKA_PLATFORM_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "domain": "acme.com",
    "tenant_id": "ten_01HN..."
  }'

Response includes DKIM CNAMEs + verification token.

{
  "id": "dom_01HN...",
  "domain": "acme.com",
  "tenant_id": "ten_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/domain-verify-poll, or call immediately:

curl -X POST https://www.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

For a demo before Acme has DNS, a key scoped to your org's sandbox domain ({org-slug}.sandbox.sendoka.com — the exact hostname is on Overview → Domains) sends with no DNS and no verification. Know its limits before you offer it: with a live key it only delivers to the verified email addresses of members of your Sendoka org — not Acme's users, and not Acme's own staff unless they are members of your org — at most 100 recipients per UTC day across the whole org, and never an audience broadcast. Anything else is refused with 403 SANDBOX_RECIPIENT_NOT_ALLOWED (or 429 SANDBOX_DAILY_LIMIT). That is enough to walk Acme through their signup flow on your own inbox; real mail to Acme's users needs Acme's domain verified (3a–3b).

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

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

Response:

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

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://www.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 = ten_01HN...}.
  • Domain check: welcome@acme.comdom_01HN... which belongs to ten_01HN... ✓.
  • Suppression filter: applies acme + org-wide suppressions only.
  • Insert: messages row with tenant_id = ten_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 = ten_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://www.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": "ten_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.

Acme's endpoints count against Acme's own copy of your plan's endpoint cap (10 on Pro), not against your org-level endpoints or Globex's — registering one endpoint per tenant never runs into it. A tenant that fills its copy gets 402 PLAN_RESOURCE_LIMIT naming that tenant: Pro plan limit reached (10/10 webhook endpoints for tenant ten_01HN...).

7. Show Acme their usage

Per-tenant usage API:

curl "https://www.sendoka.com/api/v1/tenants/ten_01HN.../usage?period=2026-04" \
  -H "Authorization: Bearer $SENDOKA_PLATFORM_KEY"
{
  "tenant_id": "ten_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://www.sendoka.com/api/v1/tenants/ten_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://www.sendoka.com/api/v1/tenants/ten_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 = ten_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