Skip to content
AgentMail
AgentMail
Advanced

Build a multi-tenant platform

Isolate each customer in their own pod, provision inboxes, domains, and scoped API keys per tenant, and route events to the right place.

This page is a guide to building a platform that gives your end customers email addresses of their own.

AgentMail’s unit for this is the pod, an isolated grouping that sits between your organization and your inboxes.

Create one pod per customer and it becomes the boundary between them:

  • API keys can be scoped to a single pod
  • Each customer’s mail and data stay private
  • Allow and block lists live per pod

Every organization starts with one pod, the Default Pod, created at sign-up. Its pod_id is the same value as your organization_id, and everything you create without naming a pod lands in it.

There is no hard limit on how many pods you add beyond it. If you only manage email for yourself, the Default Pod already holds everything and you never need to reference a pod at all.

Use pods to isolate

Everything a tenant owns hangs off their pod, so one pod_id reaches all of it, and each row stays private to that tenant:

ResourceWhat you isolate with it
InboxesThe tenant’s addresses: create, list, get, update, and delete them.
ThreadsThe tenant’s conversations across all their inboxes, attachments included.
DraftsThe tenant’s pending drafts.
DomainsThe tenant’s sending domains and their full lifecycle, zone file included.
ListsThe tenant’s allow and block lists, private to their pod.
MetricsUsage and event metrics for this tenant alone.
WebhooksWebhooks pinned to this tenant’s events.
API keysKeys that can act only inside this tenant’s pod.

Two boundaries to keep in mind:

  • Pods isolate data access, not email delivery. Inboxes in different pods can email each other like any two addresses.
  • An inbox stays in the pod it was created in. To move an address’s work into another pod, create a new inbox there.

These views across all of a tenant’s inboxes are what you build tenant dashboards from. “All unread conversations for Acme” is one call:

agentmail pods:threads list \
  --pod-id "<pod_id>" \
  --label unread
Sample response
{
  "count": 1,
  "threads": [
    {
      "organization_id": "1a2b3c4d-5e6f-4a1b-8c2d-3e4f5a6b7c8d",
      "pod_id": "2b3c4d5e-6f7a-4b2c-8d3e-4f5a6b7c8d9e",
      "inbox_id": "support@example.com",
      "thread_id": "4d5e6f7a-8b9c-4d4e-8f5a-6b7c8d9e0f1a",
      "labels": ["received", "unread"],
      "timestamp": "2026-08-25T10:31:07Z",
      "received_timestamp": "2026-08-25T10:31:07Z",
      "senders": ["A Customer <you@example.com>"],
      "recipients": ["support@example.com"],
      "subject": "Question about my order",
      "preview": "Hi, could you check the status of order 4417?",
      "last_message_id": "<010001a02e0497eb-1a2b3c4d-5e6f-4a1b-8c2d-3e4f5a6b7c8d-000000@email.amazonses.com>",
      "message_count": 1,
      "size": 4619,
      "created_at": "2026-08-25T10:31:07Z",
      "updated_at": "2026-08-25T10:31:07Z"
    }
  ]
}

Usage is a per-tenant view too. A pod’s usage metrics report inbox, message, thread, and domain counts plus storage bytes as running totals, the numbers you meter when you bill customers by usage:

curl "https://api.agentmail.to/v0/pods/<pod_id>/metrics/usage?usage_types=inbox_count,storage_bytes&period=86400" \
  -H "Authorization: Bearer $AGENTMAIL_API_KEY"

Each usage type comes back as cumulative { timestamp, value } points, and the newest point carries the current total:

Sample response
{
  "inbox_count": [
    { "timestamp": "2026-08-25T00:00:00Z", "value": 3 },
    { "timestamp": "2026-08-25T10:41:14.207Z", "value": 4 }
  ],
  "storage_bytes": [
    { "timestamp": "2026-08-25T00:00:00Z", "value": 453786 },
    { "timestamp": "2026-08-25T10:41:14.207Z", "value": 560307 }
  ]
}

Windows, resolution, the full list of usage types, and the event-count variant are on Plans and Usage Tracking.

Keys follow the same boundary. Your own organization-level key reaches every pod, so it stays on your platform’s side and never goes to a tenant. A pod-scoped key can make every call inside its pod and none outside it, and code holding one can discover its own boundary at runtime instead of being told separately. Two ways to narrow a tenant’s access further:

  • Inbox-scoped keys grant a single inbox and its messages, threads, and drafts. Mint one (client.inboxes.apiKeys.create) for an agent that runs one address.
  • A permissions object on any key create acts as a whitelist: only the operations you set to true are allowed. Permissions intersect with the key’s scope, and a key can never create a child key more powerful than itself.

A tenant holding a pod-scoped key also never has to name the pod: the plain inbox, thread, and draft calls resolve to the key’s own pod, so code written for a single-tenant setup runs unchanged when a tenant runs it (how scope shapes requests).

Route events per tenant

One organization-wide webhook receives every tenant’s events, which leaves your receiver to split them apart. Two ways to avoid that:

  • pod_ids on an organization webhook delivers only events from those pods. A webhook can watch at most 10 pods and inboxes combined.
  • Creating the webhook inside the pod (client.pods.webhooks.create) pins it to the tenant for good, and the tenant’s own pod-scoped key can manage it.
agentmail webhooks create \
  --url "https://example.com/hooks/acme" \
  --event-type message.received \
  --pod-id '["<pod_id>"]'

Save the secret. Your receiver verifies delivery signatures with it. Signature verification, retries, the full event catalog, and routing to single inboxes with inbox_ids are all on Webhooks.

Sample response
{
  "organization_id": "1a2b3c4d-5e6f-4a1b-8c2d-3e4f5a6b7c8d",
  "webhook_id": "ep_1a2b3c4d5e6f7a8b9c0d1e2f3a4",
  "url": "https://example.com/hooks/acme",
  "event_types": ["message.received"],
  "pod_ids": ["2b3c4d5e-6f7a-4b2c-8d3e-4f5a6b7c8d9e"],
  "secret": "whsec_1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d",
  "enabled": true,
  "updated_at": "2026-08-25T10:19:41Z",
  "created_at": "2026-08-25T10:19:41Z"
}

An organization holds at most 50 webhooks. On a platform with more tenants than that, point several pods at one endpoint and attribute events by the inbox_id every payload carries, since your platform knows which pod each inbox belongs to.

Provision a pod

Creating a pod takes two optional strings. name is a human-readable label for listings and defaults to My Pod. client_id is your own identifier for the tenant, usually the customer’s id in your database.

Set client_id on every pod you create. It makes the create idempotent: creating again with the same client_id returns the existing pod instead of a duplicate, even when the request carries a different name. Onboarding becomes safe to retry, a repeat create doubles as a lookup, and you never need a table mapping your customer ids to pod_ids. Both fields are set once, at creation.

agentmail pods create \
  --name "Acme Corp" \
  --client-id "customer-042"
Sample response
{
  "organization_id": "1a2b3c4d-5e6f-4a1b-8c2d-3e4f5a6b7c8d",
  "pod_id": "2b3c4d5e-6f7a-4b2c-8d3e-4f5a6b7c8d9e",
  "client_id": "customer-042",
  "name": "Acme Corp",
  "updated_at": "2026-08-25T10:18:54Z",
  "created_at": "2026-08-25T10:18:54Z"
}

pod_id is the tenant’s handle for everything that follows. It goes in the path of every pod-scoped call, and it is the value API keys and webhooks use to pin themselves to this customer. Get one pod by pod_id anytime, or list them all, newest first and paginated; the Default Pod is the entry whose pod_id equals your organization_id.

Onboarding a tenant end to end is four calls: create the pod, add their domain to it, create their first inbox, and mint an API key that can touch only their pod.

# 1. an isolated pod for the customer
agentmail pods create --client-id "customer-042" --name "Acme Corp"

# 2. the customer's domain, inside their pod
agentmail pods:domains create --pod-id "<pod_id>" --domain "example.com"

# 3. a first inbox (on agentmail.to until the domain verifies)
agentmail pods:inboxes create \
  --pod-id "<pod_id>" \
  --username "acme-support" \
  --display-name "Acme Support"

# 4. an API key that can only act inside this pod
agentmail pods:api-keys create --pod-id "<pod_id>" --name "acme-tenant-key"

Three things to know about the middle two calls:

  • The domain create returns the DNS records the customer must add. Inboxes can be created on the domain only after it reaches VERIFIED, so start tenants on @agentmail.to and create inboxes on their own domain when the domain.verified event fires. The full domain lifecycle is on Custom domains.
  • A domain belongs to exactly one pod, or to every pod when created at the organization level. It cannot be shared by a subset of pods.
  • The inbox create takes the same fields as anywhere else: username, domain, display_name, client_id, and metadata, all optional.

The key response includes the full api_key only this once. Deliver it to the tenant’s service securely. A lost key cannot be read again, so delete it and create a replacement instead:

Sample response
{
  "organization_id": "1a2b3c4d-5e6f-4a1b-8c2d-3e4f5a6b7c8d",
  "pod_id": "2b3c4d5e-6f7a-4b2c-8d3e-4f5a6b7c8d9e",
  "api_key_id": "3c4d5e6f-7a8b-4c3d-9e4f-5a6b7c8d9e0f",
  "prefix": "am_us_1a2b3c",
  "name": "acme-tenant-key",
  "created_at": "2026-08-25T10:20:11Z",
  "api_key": "am_us_1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b"
}

Store the api_key_id on your side. It is what the list and delete calls take. The prefix is the key’s first characters, enough to tell keys apart in a dashboard without holding the secret.

Decommission a pod

A pod can only be deleted once it is empty, so tear the tenant down from the bottom up:

  • Delete the tenant’s inboxes. Deleting an inbox permanently removes its messages, threads, and drafts, so there is no per-message cleanup.
  • Delete the pod’s API keys, so no credential outlives the tenant.
  • Delete the pod’s domains and webhooks, so nothing keeps pointing at a customer who is gone.

Then delete the pod itself. Success is a 204 with no body, and the pod disappears from the list.

# empty the pod first
agentmail pods:inboxes list --pod-id "<pod_id>"
agentmail pods:inboxes delete --pod-id "<pod_id>" --inbox-id "<inbox_id>"

agentmail pods:api-keys list --pod-id "<pod_id>"
agentmail pods:api-keys delete --pod-id "<pod_id>" --api-key-id "<api_key_id>"

agentmail pods:domains list --pod-id "<pod_id>"
agentmail pods:domains delete --pod-id "<pod_id>" --domain-id "<domain_id>"

# then delete the pod itself
agentmail pods delete --pod-id "<pod_id>"

Deleting a pod releases its client_id. If the customer ever comes back, the same id creates a fresh pod with a new pod_id and none of the old data.

Next Steps

Was this page helpful?Suggest editsRaise issue