Search the docs
Find a page, a section, or an endpoint.
Authentication
Bearer keys, what one reaches, and how to narrow it.
Every request carries an API key as a bearer token. Keys are server-side only and never valid in a browser — a key in client JavaScript is a key anyone who opens devtools now owns.
curl https://pidgeon.ai/api/v1/identities -H "Authorization: Bearer $PIDGEON_API_KEY"What a key can reach
What it was given, and no more. A key is one of three kinds:
- A list of addresses, set when it is created and editable afterwards. A key with an empty list reaches no address at all, which is a perfectly good key: it can still read events and manage webhooks.
- One pod (
pod_id): every address in that pod, including ones added later, and the pod's own domains. The key you hand one of your customers. - Every address on the account (
reaches_all), now and later. Only an owner chooses it, in the dashboard or on an app's consent screen; a key cannot mint one.
Every new key says which, and its access level. Nothing reaches every address unless somebody asked for it.
Within that list, each address is narrowed by its own capabilities. Capabilities are derived from the address's type and the plan rather than stored, so they cannot drift from what the product shows: an application address has no inbox, and asking for its threads returns nothing rather than an error.
A list of addresses is a snapshot, not a subscription. Creating payroll@
tomorrow does not hand it to a key you issued last month — except when that
key created the address itself, which is precisely its business.
What a key can do
Each key also has an access level, the most it can do on any address it
reaches. Set it with access when you create the key, which is required; it
never changes.
| Level | access | What it can do |
|---|---|---|
| Read only | read | Fetch mail, threads and addresses. |
| Read and draft | draft | Manage the inbox and write drafts. This key can never send. |
| Read and send | send | Everything above, and send as the addresses it reaches. |
| Full access | admin | Everything above, and create, change and delete addresses. |
Read and draft is for an agent whose work a person signs off. It reads
and searches, labels, archives, trashes, marks spam, stars, updates threads
and hands them to a person, and creates, changes and deletes drafts. Every
path that puts mail on the wire answers 403: sending, replying, forwarding,
sending or scheduling a draft, calendar invitations and answers, and app
passwords for IMAP and SMTP. A draft a person has scheduled is theirs: the
key cannot change it until the schedule is cancelled. Read only changes
nothing: archiving, trashing, emptying the trash and labelling need Read and
draft. Creating an address needs full access over the whole account or over
a pod, and changing or deleting one needs full access on it; a key limited to
some addresses creates none, at any level. The pattern is in
Let your agent draft, approve and send yourself.
A key can only create keys at or below its own level, so a read-and-draft key can never make one that sends.
Asking a key what it is
Everything else here answers what is in an account. GET /v1/me answers
what the credential in your hand may do — which you otherwise learn by trying
something and reading the refusal, inside a model's tool call, where nobody
sees it.
curl https://pidgeon.ai/api/v1/me -H "Authorization: Bearer $PIDGEON_API_KEY"It returns the key's ceiling, how many addresses it reaches, and the
workspace. key.test says whether it is a test key:
read that, not the token's prefix. key.rotated_at and
key.previous_token_expires_at say when it was last rotated and when the
old secret stops. Not the addresses themselves — GET /v1/identities lists those,
with a cursor — but the count, which is what tells a key that reaches nothing
apart from one that is misconfigured.
The rung is the thing that surprises people later. A revoked key fails at
once and a key granted nothing returns an empty list, but a key at read
installs perfectly and then cannot answer an email. Check it at startup rather
than at the first send. It costs nothing: no write, no side effect, and it
does not count against anything your plan sells.
Keys that mint keys
A key can create another key, scoped to a subset of what it reaches. This is what makes per-customer credentials something software provisions: mint a key for a tenant, hand it over, revoke it when they leave.
curl https://pidgeon.ai/api/v1/api-keys -H "Authorization: Bearer $PIDGEON_API_KEY" -H "Content-Type: application/json" -d '{ "name": "Acme", "identity_ids": ["identity-id"], "access": "send" }'Pass one of identity_ids or pod_id, and access. A key cannot ask
for reaches_all: delegation never hands out the widest answer. Only a key at
full access over the whole account may mint a pod key. Every id has to be one
the calling key administers, at no more than its own level: a key cannot
delegate reach it does not have, and asking for one it cannot see returns the
same "not found" as an address in somebody else's account.
Minting takes an Idempotency-Key, so a mint whose answer was lost can be
retried safely. The replay is the same key, marked Idempotent-Replay: true,
with token: null: the secret is shown once and never stored. Rotate it for
a new secret, or revoke it and mint again.
Explicit scope
Until 26 November 2026, a mint without access still works. The new key
gets the calling key's level, as it always did, and the answer says so:
- a
Deprecationand aSunsetheader, and aLinkto this section; - a
warningsentry besidedata, naming the field and the date:
{
"data": { "id": "…", "access": "send", "token": "pgn_live_…" },
"warnings": [
{
"code": "implicit_scope",
"fields": ["access"],
"message": "This key was made without `access`, so it took a default. …",
"sunset": "2026-11-26T00:00:00.000Z"
}
]
}
From 26 November 2026 the same request is a 400 with field: "access".
The same holds for key on POST /v1/pods, where the field is key.access.
Keys you already have are unchanged. The SDKs from 1.40.0, the CLI from
1.24.0 and the dashboard send access on every mint.
Rotating
POST /v1/api-keys/{id}/rotate gives a key a new secret and keeps
everything else: its id, what it reaches, its ceiling, its expiry, and
every key it minted, which keep working. That is the difference from
revoking. If your admin key mints a key per customer, rotating it does not
take a single customer down.
curl -X POST https://pidgeon.ai/api/v1/api-keys/$KEY_ID/rotate -H "Authorization: Bearer $PIDGEON_API_KEY" -H "Content-Type: application/json" -d '{ "grace_period_seconds": 3600 }'- The old secret stops at once, unless you pass
grace_period_seconds. It can keep working for at most 86400 seconds, a day, so your servers have time to move over. The key'sprevious_token_expires_atsays when it stops. - One previous secret at a time. Rotating again inside the window ends the older secret immediately.
- Who may rotate: the key itself, a key that minted it, or an owner of the account in the dashboard. Not a sibling and not its parent: those get the same "not found" as a key that does not exist.
- Not idempotent. Each call issues a new secret, and the response is the only place it appears, so retrying a call that timed out leaves you holding the secret from the call that answered.
- Idempotency keys survive it. They belong to the key's id, not its secret, so a retry that started under the old secret replays under the new one.
A key never expires unless you give it expires_at when you create it. A
key that stops working on a schedule nobody chose is an outage with a
calendar invite.
Revoking
A revoked key stops working immediately, and so does every key it minted, however deep. That is deliberate: a leaked key's first useful move is to mint a second one and keep it, so a revoke that stopped at the key you knew about would leave you believing you had rotated something you had not.
A key may revoke itself. If your software detects it has been compromised it can say so without waiting for a person.
The event log keeps which key made which call, so a key you are unsure about can be answered rather than guessed at.
The SDK
@pidgeon-ai/sdk is a thin typed client over exactly these endpoints, plus
the webhook verifier. It sets the header, parses the envelope, and turns a
failure into an error it returns rather than throws, carrying the same
code the API returned — nothing else, which is why it is worth using.
npm install @pidgeon-ai/sdkEvery TypeScript sample in these docs is written against it. The cURL and Python samples are the same calls without one, so nothing here is only reachable through a package.
Idempotency
Everything that sends mail accepts an Idempotency-Key header: sending,
replying, reply-all, forwarding, sending or scheduling a draft, answering an
invitation, and creating, changing or cancelling a calendar event that mails
its attendees. So do the creates — addresses, API keys, drafts, domains,
labels, pods, routing rules, webhooks.
The same key with the same request returns the first response, marked
Idempotent-Replay: true, and sends nothing. Two 409s say the rest:
error.code | Means | Do |
|---|---|---|
idempotency_in_progress | The first request with this key has not answered yet. | Wait Retry-After seconds and send again. |
idempotency_mismatch | This key was already used with a different request, or by a different key. A bug in the caller, not a retry. | Use a new key. Retrying never works. |
A failure is not kept, so retrying after one runs the request again. Keys are kept for 24 hours, and shared across the account and its endpoints.
Make one key per send and reuse it on that send's retries. The SDKs take it
as an argument — headers: { 'Idempotency-Key': … } in TypeScript,
idempotency_key= in Python — and do not make one up for you.
curl https://pidgeon.ai/api/v1/messages -H "Authorization: Bearer $PIDGEON_API_KEY" -H "Idempotency-Key: 8f21c-invoice-4417" -H "Content-Type: application/json" -d '{ "from": "hello@yourdomain.com", "to": "a@example.com", "text": "Hi" }'