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.com→dom_01HN...which belongs totnt_01HN...✓. - Suppression filter: applies
acme+ org-wide suppressions only. - Insert:
messagesrow withtenant_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
- One platform-root key lives on your server, creates everything.
- Each of your customers is a
tenant. Storetenant_idkeyed by your own customer id. - Mint one tenant-bound key per tenant. Scope it to their domains.
- Hand it to them; they send as if they had their own Sendoka account.
- Webhooks, suppressions, usage are all tenant-scoped automatically.
- 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
- Per-tenant suppression recipes — isolation details
- Event catalog — what events a tenant endpoint can subscribe to
- platforms.md — API reference + deferred items