# Build a multi-tenant platform (/advanced/multi-tenant)

<!-- agent-signals: reading_time_min: 7 · est_tokens: 2908 · updated: 2026-09-06 -->
Related: [Webhooks](/advanced/webhooks.md), [WebSockets](/advanced/websockets.md), [Agent safety](/advanced/safety.md), [Custom domains](/advanced/custom-domains.md), [Deliverability and warmup](/advanced/deliverability.md), [AgentID public-key authentication](/advanced/agentid.md)



# Provision an isolated pod per customer

A pod is an isolated workspace inside your AgentMail organization, and nothing in one pod can see into another. Create one pod per tenant, provision domains, inboxes, scoped API keys, and webhooks inside it, and delete the pod when the tenant offboards.

## Do this

Onboard a tenant with four calls: create the pod, add the tenant's domain, create a first inbox, mint a pod-scoped key.

```bash
# 1. Create the tenant's pod. client_id is your own tenant id and makes the
#    create idempotent: repeating with the same client_id returns the existing pod.
curl -X POST "https://api.agentmail.to/v0/pods" \
  -H "Authorization: Bearer $AGENTMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "client_id": "customer-042", "name": "Acme Corp" }'
# save pod_id from the response, it goes in the path of every call below

# 2. Add the tenant's domain to the pod. The response carries the DNS records
#    the tenant must add.
curl -X POST "https://api.agentmail.to/v0/pods/$POD_ID/domains" \
  -H "Authorization: Bearer $AGENTMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "domain": "example.com" }'

# 3. Create a first inbox, on agentmail.to until the domain verifies.
curl -X POST "https://api.agentmail.to/v0/pods/$POD_ID/inboxes" \
  -H "Authorization: Bearer $AGENTMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "username": "acme-support", "display_name": "Acme Support" }'

# 4. Mint a key that can act only inside this pod. The response includes the
#    full api_key exactly once. Deliver it to the tenant securely.
curl -X POST "https://api.agentmail.to/v0/pods/$POD_ID/api-keys" \
  -H "Authorization: Bearer $AGENTMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "acme-tenant-key" }'
```

Create inboxes on the tenant's own domain when the `domain.verified` event fires.

## SDK

Install: `npm install -g agentmail-cli` (CLI), `npm install agentmail` (TypeScript), `pip install agentmail` (Python).

| Operation          | CLI                                                                                           | TypeScript                                                                           | Python                                                                                          |
| ------------------ | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- |
| Create pod         | `agentmail pods create --client-id "customer-042" --name "Acme Corp"`                         | `client.pods.create({ clientId, name })`                                             | `client.pods.create(client_id=..., name=...)`                                                   |
| List and get pods  | `agentmail pods list` and `agentmail pods get --pod-id "..."`                                 | `client.pods.list()` and `client.pods.get("<pod_id>")`                               | `client.pods.list()` and `client.pods.get(pod_id=...)`                                          |
| Add domain         | `agentmail pods:domains create --pod-id "..." --domain "example.com"`                         | `client.pods.domains.create(podId, { domain })`                                      | `client.pods.domains.create(pod_id=..., domain=...)`                                            |
| Create inbox       | `agentmail pods:inboxes create --pod-id "..." --username "..."`                               | `client.pods.inboxes.create(podId, { username, displayName })`                       | `client.pods.inboxes.create(pod_id=..., username=..., display_name=...)`                        |
| Mint pod key       | `agentmail pods:api-keys create --pod-id "..." --name "..."`                                  | `client.pods.apiKeys.create(podId, { name })`                                        | `client.pods.api_keys.create(pod_id=..., name=...)`                                             |
| Tenant thread view | `agentmail pods:threads list --pod-id "..." --label unread`                                   | `client.pods.threads.list(podId, { labels })`                                        | `client.pods.threads.list(pod_id=..., labels=[...])`                                            |
| Tenant webhook     | `agentmail webhooks create --url "..." --event-type message.received --pod-id '["<pod_id>"]'` | `client.webhooks.create({ url, eventTypes, podIds })`                                | `client.webhooks.create(url=..., event_types=[...], pod_ids=[...])`                             |
| Empty the pod      | `agentmail pods:inboxes delete`, `pods:api-keys delete`, `pods:domains delete`                | `client.pods.inboxes.delete(podId, inboxId)`, same shape for `apiKeys` and `domains` | `client.pods.inboxes.delete(pod_id=..., inbox_id=...)`, same shape for `api_keys` and `domains` |
| Delete pod         | `agentmail pods delete --pod-id "..."`                                                        | `client.pods.delete(podId)`                                                          | `client.pods.delete(pod_id=...)`                                                                |

Client setup and the full surface reference: /integrations/sdks-and-cli

## Facts

* `POST /v0/pods` takes two optional strings, `name` (a label for listings, defaults to `My Pod`) and `client_id` (your tenant's id in your own database). Both are set once, at creation.
* Repeating `POST /v0/pods` with the same `client_id` returns the existing pod instead of creating a duplicate, even when the request carries a different `name`. A repeat create doubles as a lookup.
* Every organization starts with a Default Pod whose `pod_id` equals the `organization_id`. Everything created without naming a pod lands in the Default Pod.
* There is no hard limit on the number of pods beyond the Default Pod.
* Pods isolate data access, not email delivery. Inboxes in different pods can email each other like any two addresses.
* `GET /v0/pods` returns pods newest first with a `next_page_token` when more remain. Params: `limit` (integer), `page_token` (string), `ascending` (boolean, oldest first).
* `POST /v0/pods/{pod_id}/domains` returns the DNS `records` the tenant must add. Inboxes can be created on the domain only after it reaches `VERIFIED`.
* A domain belongs to exactly one pod, or to every pod when created at the organization level.
* `POST /v0/pods/{pod_id}/inboxes` takes `username`, `domain`, `display_name`, `client_id`, and `metadata`, all optional.
* `POST /v0/pods/{pod_id}/api-keys` returns the full `api_key` only in the create response. Store the `api_key_id`, the value list and delete calls take. `prefix` is the key's first characters, for telling keys apart in a dashboard.
* A pod-scoped key can make every call inside its pod and none outside it. An organization-level key reaches every pod, so it stays on the platform side and never goes to a tenant.
* `GET /v0/auth/me` tells a key its own `pod_id` and scope, so a tenant's service discovers its boundary at runtime.
* `POST /v0/inboxes/{inbox_id}/api-keys` (`client.inboxes.apiKeys.create`) mints an inbox-scoped key granting a single inbox and its messages, threads, and drafts.
* A `permissions` object on any key create is a whitelist: only operations set to `true` are allowed, and permissions intersect with the key's scope. Keys created without a whitelist have every permission.
* Resource routes under `/v0/pods/{pod_id}`: `/inboxes`, `/threads`, `/drafts`, `/domains`, `/lists`, `/metrics`, `/webhooks`, `/api-keys`.
* `GET /v0/pods/{pod_id}/threads` takes the same filters as the inbox thread list: `labels`, `senders`, `recipients`, `subject`, `before`, `after`, and the `include_*` flags. Each entry carries its `inbox_id`.
* `GET /v0/pods/{pod_id}/metrics/usage` reports the tenant's inbox, message, thread, and domain counts plus storage bytes, the numbers to meter when billing customers by usage.
* With a pod-scoped key, unprefixed paths such as `/v0/inboxes` resolve to the key's own pod, so code written against the normal paths runs unchanged for a tenant.
* `pod_ids` on `POST /v0/webhooks` delivers only events from the listed pods. A webhook can watch at most 10 pods and inboxes combined.
* A webhook created at `POST /v0/pods/{pod_id}/webhooks` is pinned to that pod for good, and the tenant's own pod-scoped key can manage it.
* An organization holds at most 50 webhooks. With more tenants than that, point several pods at one endpoint and attribute events by the `inbox_id` every payload carries.
* Save the webhook `secret` from the create response. The receiver verifies delivery signatures with it.
* `DELETE /v0/pods/{pod_id}` succeeds only on an empty pod. Success is `204` with no body.
* Deleting an inbox permanently removes its messages, threads, and drafts.
* Deleting a pod releases its `client_id`. The same `client_id` then creates a fresh pod with a new `pod_id` and none of the old data.

## Not supported

* An inbox cannot move between pods. Create a new inbox in the target pod instead.
* A domain cannot be shared by a subset of pods. It belongs to one pod or to every pod.
* The Default Pod can never be deleted. Its `409 cannot_delete` is permanent, do not retry.
* A pod that still holds inboxes or pod-scoped API keys cannot be deleted. Empty it first.
* A lost `api_key` cannot be read again after creation. Delete the key and create a replacement.
* Pod `name` and `client_id` cannot be changed after creation.
* A key can never create a child key more powerful than itself.

## Errors

| Error                | HTTP | Cause                                                                                    | Fix                                                                                              |
| -------------------- | ---- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `missing_permission` | 403  | The key carries a permissions whitelist without `pod_create`                             | Use a key that has `pod_create`, or one created without a whitelist                              |
| `not_found`          | 404  | The `pod_id` is wrong or not visible to the key (typo, or key scoped to a different pod) | Check the `pod_id`, or call with a key that can see the pod                                      |
| `cannot_delete`      | 409  | The pod still holds inboxes or pod-scoped API keys, or it is the Default Pod             | Delete the blocking resources named in the message and retry. Never retry the Default Pod delete |

## Verify

```bash
curl "https://api.agentmail.to/v0/pods/$POD_ID" \
  -H "Authorization: Bearer $AGENTMAIL_API_KEY"
```

`200` with a pod object echoing `pod_id` and `client_id` confirms the tenant is provisioned. After offboarding, the same call returns `404` and the pod is gone from `GET /v0/pods`.

## Related

* /advanced/errors - the error codes and retry rules provisioning code should handle
* /advanced/plans-and-usage - plan limits and the usage metrics to meter per tenant
* /advanced/custom-domains - the full domain verification lifecycle
* /advanced/webhooks - signature verification, retries, and the event catalog
* /core/conversations - the thread filters the pod thread list shares
* /quickstart - get an API key first
