# Pidgeon for platforms

> Give each of your users their own Pidgeon address: an admin key and the keys it mints, provisioning a pod, address and key in one idempotent call, rotate versus revoke, test mode for CI, and teardown.

If your product gives each of its own users an address — every agent you
host, every customer you serve — this is the shape that keeps them apart.
Each user gets a [pod](https://pidgeon.ai/docs/pods.md), one address in it, and a key that
reaches that pod and nothing else. Your server holds one admin key and
makes the rest.

```text
POST   /v1/pods                                    a pod, its address and its key, in one call
GET    /v1/identities?pod_id=…                     a pod's addresses
GET    /v1/api-keys?pod_id=…                       a pod's keys
POST   /v1/api-keys/{id}/rotate                    a new secret for a key; nothing below it changes
DELETE /v1/api-keys/{id}                           a key, and every key it minted, stop working
DELETE /v1/pods/{id}?delete_addresses=true         a pod, its keys, its addresses and their mail, gone
```

## Your admin key, and the keys it mints

The key on your server is an **admin key**: a key over every address in
your account, at `admin`. Make it in Developers → API keys, and leave the
addresses unchosen. It is the only key that can create or delete pods,
and the only one that can mint a key for a pod.

A **pod key** is what you hand one user. It reaches every address in its
pod, including ones the pod gains later, and nothing outside it. It cannot
see another pod, list your keys, mint a key for another pod, or create or
delete a pod, its own included. Asking about another pod gets `403` or
`404`, and the answer says nothing about what is in it.

## Provisioning a user in one call

`POST /v1/pods` with `address` and `key` makes the pod, an address in it and
a pod key for it. All three are made or none is: if the address can't be
made, the pod is removed again and you get the error.

```bash
curl https://pidgeon.ai/api/v1/pods \
-H "Authorization: Bearer $PIDGEON_ADMIN_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: provision-user-8812" \
-d '{
  "name": "user-8812",
  "client_id": "user-8812",
  "address": { "address": "user-8812@yourapp.pidgeon.email", "type": "agent" },
  "key": { "access": "send" }
}'
```

```ts
const { data } = await podsCreate({
body: {
  name: 'user-8812',
  client_id: 'user-8812',
  address: { address: 'user-8812@yourapp.pidgeon.email', type: 'agent' },
  key: { access: 'send' },
},
headers: { 'Idempotency-Key': 'provision-user-8812' },
throwOnError: true,
});

const { id: podId, address, key } = data.data;
// Shown once. Store it before you do anything else.
await storeSomewhereSafe(podId, address!.email, key!.token!);
```

```python
from pidgeon.api.pods import pods_create
from pidgeon.models import (
  PodsCreateBody,
  PodsCreateBodyAddress,
  PodsCreateBodyAddressType,
  PodsCreateBodyKey,
  PodsCreateBodyKeyAccess,
)

created = pods_create.sync(
  client=client,
  body=PodsCreateBody(
      name="user-8812",
      client_id="user-8812",
      address=PodsCreateBodyAddress(
          address="user-8812@yourapp.pidgeon.email",
          type_=PodsCreateBodyAddressType.AGENT,
      ),
      key=PodsCreateBodyKey(access=PodsCreateBodyKeyAccess.SEND),
  ),
  idempotency_key="provision-user-8812",
)
```

```json
{
"data": {
  "id": "2f0f6a1c-9a4e-4e26-9a60-2b4c0f5e1d33",
  "name": "user-8812",
  "client_id": "user-8812",
  "is_default": false,
  "identity_count": 1,
  "created_at": "2026-10-24T09:00:00.000Z",
  "address": { "id": "…", "email": "user-8812@yourapp.pidgeon.email", "pod_id": "2f0f6a1c-…", "…": "…" },
  "key": { "id": "…", "pod_id": "2f0f6a1c-…", "access": "send", "test": false, "token": "pgn_live_…", "…": "…" }
}
}
```

- **`address`** takes the same fields as `POST /v1/identities`: an
  `address`, or a `local_part` with a `domain` or `domain_id`, and `type`,
  `display_name` and the rest. It always goes in the new pod.
- **`key`** takes `name` (the pod's name if you leave it out), `access`,
  `expires_at` and `test`. `{}` is a pod key at your admin key's rung.
  `access: "send"` is the usual choice for a user's agent: it can read and
  send, and cannot delete its address or change who reaches it.
- Leave either out and it is not made. A body with neither is a plain pod.
- `name` and `client_id` are each unique in your account, so a second call
  for the same user is a `409`, not a second pod.

### The token is shown once, and a retry says so

Send an `Idempotency-Key`. A retry with the same key and body returns the
**same** pod, address and key, marked `Idempotent-Replay: true`, with
`"token": null`. Pidgeon never stores a key's secret, so it cannot show it
twice.

If your first answer was lost and you never saw the token, rotate the key:
`POST /v1/api-keys/{id}/rotate` with the `key.id` from the replay gives it a
new secret, and the pod and address are untouched. An `Idempotency-Key`
is kept for a day ([Idempotency](https://pidgeon.ai/docs/authentication.md#idempotency)).

## Rotate or revoke

Two different tools, and the difference is everything below the key.

- **Rotate** (`POST /v1/api-keys/{id}/rotate`) gives a key a new secret and
  keeps its id, so every key it minted keeps working. Rotate your admin key
  on a schedule, or when someone who had it leaves: no user goes down. The
  old secret can keep working for up to a day with `grace_period_seconds`.
- **Revoke** (`DELETE /v1/api-keys/{id}`) ends a key **and every key it
  minted**, however deep. Revoking a user's pod key cuts that user off.
  Revoking your admin key cuts off every user it provisioned, at once. That
  is the kill switch: use it when the admin key has leaked, not to replace
  it.

Details and the rules on who may do which are in
[Authentication](https://pidgeon.ai/docs/authentication.md#rotating).

## Test mode, for CI and development

A [test key](https://pidgeon.ai/docs/test-mode.md) (`pgn_test_…`) runs the whole pipeline and
nothing it sends leaves Pidgeon or counts against your plan. Give each user
a test key as well as a live one, from the same call:

```json
POST /v1/pods
{ "name": "user-8812-ci", "address": { "address": "user-8812-ci@yourapp.pidgeon.email" }, "key": { "test": true } }
```

Or mint one for an existing pod with `POST /v1/api-keys` and
`{ "pod_id": "…", "test": true }`.

A test key can't create or delete pods or addresses: a test key calling
`POST /v1/pods` gets `403`. Provision with your live admin key, then hand
the test key to CI or a development environment.

### Simulated inbound

Nobody outside will ever answer a test send, so `POST /v1/test/inbound`
plays the other side. It delivers a message to one of the test key's
addresses through the real inbound pipeline: the sender rules, the spam
filter, the Firewall, threading, `message.received` and your webhooks.

```bash
curl https://pidgeon.ai/api/v1/test/inbound \
-H "Authorization: Bearer $PIDGEON_TEST_KEY" \
-H "Content-Type: application/json" \
-d '{
  "to": "user-8812-ci@yourapp.pidgeon.email",
  "from": "customer@example.com",
  "subject": "Where is my order?",
  "text": "It never arrived."
}'
```

```ts
await testInbound({
body: {
  to: 'user-8812-ci@yourapp.pidgeon.email',
  from: 'customer@example.com',
  subject: 'Where is my order?',
  text: 'It never arrived.',
},
throwOnError: true,
});

// Your agent gets message.received and answers it. The answer goes nowhere.
```

## Listing one user's pod

Every address carries its `pod_id`, and every key its `pod_id`, so a pod
lists like this:

```bash
curl "https://pidgeon.ai/api/v1/identities?pod_id=$POD_ID" \
-H "Authorization: Bearer $PIDGEON_ADMIN_KEY"

curl "https://pidgeon.ai/api/v1/api-keys?pod_id=$POD_ID" \
-H "Authorization: Bearer $PIDGEON_ADMIN_KEY"
```

```ts
const { data: addresses } = await identitiesList({
query: { pod_id: podId },
throwOnError: true,
});
const { data: keys } = await keysList({ query: { pod_id: podId }, throwOnError: true });
```

The key list includes revoked keys, so you can tell which key was which
after the fact. It needs your admin key; a pod key gets `403`.

## Tearing a user down

`DELETE /v1/pods/{id}` removes a user from Pidgeon. **It deletes their mail,
permanently, and nothing can bring it back.** In order, it:

1. revokes every key of the pod, and every key those minted, so nothing can
   send while the rest happens;
2. deletes each address in the pod with all of its mail: messages,
   threads, attachments and the stored originals;
3. deletes the pod.

A pod that still holds an address is refused with `409` unless you pass
`delete_addresses=true`, so deleting mail is never something a call does
by accident.

```bash
curl -X DELETE "https://pidgeon.ai/api/v1/pods/$POD_ID?delete_addresses=true" \
-H "Authorization: Bearer $PIDGEON_ADMIN_KEY"
```

```ts
const { data } = await podsDelete({
path: { id: podId },
query: { delete_addresses: 'true' },
throwOnError: true,
});
```

```python
from pidgeon.api.pods import pods_delete
from pidgeon.models import PodsDeleteDeleteAddresses

deleted = pods_delete.sync(
  pod_id,
  client=client,
  delete_addresses=PodsDeleteDeleteAddresses.TRUE,
)
```

```json
{ "data": { "id": "2f0f6a1c-…", "deleted": true, "addresses_deleted": 1, "keys_revoked": 2 } }
```

- **It needs your live admin key.** A pod key gets `403`, on its own pod
  too. So does a test key.
- **Refused, always:** your account's default pod, and a pod holding a
  domain. Delete the domain first (`DELETE /v1/domains/{id}`).
- **Refused before anything happens:** a pod holding a person's own mailbox,
  which only that person can delete.
- **If it fails part way, call it again.** It carries on from where it
  stopped. Once the pod is gone, the call answers `404`.
- **To keep the mail**, don't delete the pod. Suspend the addresses
  (`PATCH /v1/identities/{id}` with `"status": "suspended"`) and revoke the
  pod's key: nothing can send or arrive, and the conversation stays on
  record.

## Limits

Each pod counts against your plan's pods, and each key, live or test,
against its active keys. A user with a live key and a test key uses two.
Revoked keys count for nothing. The numbers per plan are in
[Limits](https://pidgeon.ai/docs/limits.md).
