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; where the two differ, platforms.md is the reference.

0. Two ways to send for a tenant

Every request is made with a key from your Sendoka org. What decides the tenant is one of:

A. Platform key + X-Sendoka-Tenant-Ref B. Tenant-bound key
Who holds the key Only your server Your customer (e.g. in their own integration)
How the tenant is chosen The header names your id for the customer; the tenant is looked up, or created on its first live request The key is bound to one tenant at mint time
Best for App platforms (a Shopify app, a CRM) that send on their merchants' behalf Handing a customer their own credentials

Most app platforms want A: one platform-root key on your server, and the header on every call. No per-customer key to mint, store or rotate. Path B is steps 4–5 below.

   Your SaaS UI                          Your server (platform-root key)
   ────────────                          ───────────────────────────────
   Acme signs up                         ├─ POST /api/v1/tenants        (or let the header create it)
         │                               ├─ POST /api/v1/domains        tenant_id = ten_…
         ▼                               │
   Acme adds DNS records                 ├─ path A: POST /api/v1/emails
                                         │          X-Sendoka-Tenant-Ref: cust_12345
                                         └─ path B: POST /api/v1/keys   tenant_id = ten_…
                                                    → hand the key to Acme

Key idea: the platform-root key never leaves your server. Acme only ever sees a tenant-bound key, if anything — it can send and read its own messages, but can't see siblings.

1. Grab a platform-root key

Overview → API keys → New key, and leave Tenant on "Any tenant". Copy it.

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 (201):

{
  "id": "ten_...",
  "name": "Acme Corp",
  "slug": "acme",
  "external_ref": "cust_12345",
  "status": "active",
  "suspension_reason": null,
  "suspended_at": null,
  "monthly_email_cap": null,
  "monthly_sms_cap": null,
  "created_at": "...",
  "updated_at": "..."
}

The slug is unique per org (human-readable). external_ref is your pointer back to your own DB row; it is unique per org and matched case-insensitively. Keep the ten_… id: every /v1/tenants/{id} call takes it. It is also shown on the tenant's page in the dashboard (Overview → Tenants → the tenant).

Skipping this step is fine on path A: the first live request carrying X-Sendoka-Tenant-Ref: cust_12345 creates the tenant (name and ref cust_12345) and returns its id in the X-Sendoka-Tenant-Id response header, with X-Sendoka-Tenant-Created: true. A sok_test_* key never creates one, and neither does a scoped key without write:tenants — see platforms.md → Auto-provisioning tenants.

3. Acme wants to send from welcome@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_..."
  }'

Response (201, abridged):

{
  "id": "dom_...",
  "domain": "acme.com",
  "tenant_id": "ten_...",
  "status": "pending",
  "dkim_tokens": ["abc...", "def...", "ghi..."],
  "region": "us-east-1",
  "verified_at": null,
  "created_at": "..."
}

Each DKIM token is one CNAME record for Acme to add at their DNS provider:

abc...._domainkey.acme.com   CNAME   abc....dkim.amazonses.com

Show these records in your Acme-facing UI. See domains.md for the full DNS story.

3b. Watch for verification

Sendoka re-checks pending domains every 5 minutes, or check now:

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

Status flips pending → verified once the CNAMEs resolve. That 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. Path A — send for Acme with the header

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

The request runs as Acme: Acme's domains, suppressions, templates, caps, usage and webhooks apply, exactly as if a key bound to Acme had sent it. The header works the same on /v1/sms and the other v1 routes.

5. Path B — mint Acme a tenant-bound key

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_...",
    "environment": "live",
    "scopes": ["send:email", "send:sms", "read:messages"],
    "allowed_domain_ids": ["dom_..."]
  }'

Response (201):

{
  "id": "key_...",
  "key": "sok_live_...",
  "name": "Acme primary",
  "environment": "live",
  "tenant_id": "ten_...",
  "scopes": ["send:email", "send:sms", "read:messages"],
  "rate_limit_per_minute": null,
  "expires_at": null,
  "created_at": "..."
}

key is shown once — forward it to Acme over a secure channel. Sendoka stores only a hash and the last four characters, so it can never be shown again. Owners can mint the same key from the dashboard (API keys → New key → pick the tenant).

allowed_domain_ids scopes this key to acme.com only. If Acme later adds a second domain, mint a key that includes it.

Acme then sends with its own key, no header:

headers: { Authorization: `Bearer ${process.env.SENDOKA_KEY}` }

If Acme tries to send from a domain the key doesn't allow, they get:

{
  "error": {
    "type": "authentication_error",
    "code": "DOMAIN_NOT_ALLOWED_FOR_KEY",
    "message": "Key is not allowed to send from this domain"
  }
}

Either way, behind the scenes: the message is stored with Acme's tenant_id, Acme's suppressions and the org-wide ones are applied, usage counts against your bill and Acme's per-tenant counter, and only webhook endpoints that are org-wide or Acme's receive the events.

6. Set up Acme's webhooks (optional)

Acme wants events for their own dashboard. Your server registers the endpoint for them:

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

The response includes secret — forward it to Acme, who uses 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_...).

7. Read Acme's messages and usage

List one tenant's messages by sending the same header on the read (or by reading with Acme's tenant-bound key):

curl "https://www.sendoka.com/api/v1/emails?limit=20" \
  -H "Authorization: Bearer $SENDOKA_PLATFORM_KEY" \
  -H "X-Sendoka-Tenant-Ref: cust_12345"

Every message object the list and by-id reads return carries tenant_id (null for a send made with no tenant). The list routes take no tenant_id query parameter — without the header, a platform-root key lists every tenant's messages. Note that a live read carrying a ref that matches no tenant creates the tenant, like any other live request.

In the dashboard, Email and SMS logs have a tenant filter and column, and a search matches the tenant's ref or slug.

Per-tenant usage for a calendar month:

curl "https://www.sendoka.com/api/v1/tenants/ten_.../usage?period=2026-04" \
  -H "Authorization: Bearer $SENDOKA_PLATFORM_KEY"
{
  "tenant_id": "ten_...",
  "period": "2026-04",
  "usage": { "email": 18200, "sms": 112 },
  "breakdown": [
    { "channel": "email", "environment": "live", "count": 18200 },
    { "channel": "sms", "environment": "live", "count": 112 }
  ]
}

The counts are for the calling key's environment: a live key reads live usage, a sok_test_* key reads test usage. Use it to drive Acme's "messages this month" counter, or to bill them.

8. Suspend Acme

curl -X POST https://www.sendoka.com/api/v1/tenants/ten_.../suspend \
  -H "Authorization: Bearer $SENDOKA_PLATFORM_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "reason": "Non-payment" }'

Every Acme-bound key now answers 403 TENANT_SUSPENDED, a request with X-Sendoka-Tenant-Ref: cust_12345 answers 409 TENANT_NOT_USABLE, and Acme's still-scheduled sends are canceled. History is preserved. To resume:

curl -X POST https://www.sendoka.com/api/v1/tenants/ten_.../unsuspend \
  -H "Authorization: Bearer $SENDOKA_PLATFORM_KEY"

Owners can also reactivate a suspended tenant from its dashboard page.

9. Acme leaves — archive, restore, erase

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

DELETE is a soft archive, not a delete: the tenant's status becomes archived, its still-scheduled sends are canceled, and its messages, suppressions and usage stay queryable. It is idempotent — a retried uninstall webhook answers 200. Only an active tenant can be archived; unsuspend a suspended one first.

  • Acme comes back: POST /api/v1/tenants/ten_.../restore brings the same tenant back, with its suppression list intact. Don't create a second tenant.
  • Acme asks for erasure (GDPR, Shopify shop/redact): POST /api/v1/tenants/ten_.../erase, repeated until it answers done: true. It is irreversible and only runs on an archived or suspended tenant. See platforms.md → Erase for exactly what it removes.

Recap — what you learned

  1. One platform-root key lives on your server and creates everything.
  2. Each of your customers is a tenant. Your own customer id is its external_ref.
  3. Send for a tenant with X-Sendoka-Tenant-Ref, or hand the tenant a key bound to it, scoped to its domains.
  4. Webhooks, suppressions and usage are tenant-scoped automatically.
  5. You see everything; a tenant-bound key sees only its tenant.

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.
  • Archive is not erase. Archived data stays queryable; use /erase when the customer asks for deletion.
  • A read without the header is org-wide. A platform-root key listing /v1/emails with no X-Sendoka-Tenant-Ref sees every tenant's messages.
  • 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