# Authentication

> Bearer keys for the Pidgeon API: creating one, what a key reaches, and narrowing it to a single address and a single rung of access.

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.

```bash
curl https://pidgeon.ai/api/v1/identities -H "Authorization: Bearer $PIDGEON_API_KEY"
```

```ts
import { client, identitiesList } from '@pidgeon-ai/sdk';

// The SDK sets the header for you. This is the only place it is
// written out, so you can see what it is doing.
client.setConfig({ auth: () => process.env.PIDGEON_API_KEY! });

const { data } = await identitiesList();
```

```python
from pidgeon import AuthenticatedClient
from pidgeon.api.identities import identities_list

client = AuthenticatedClient(
  base_url="https://pidgeon.ai/api/v1",
  token=os.environ["PIDGEON_API_KEY"],
)

page = identities_list.sync(client=client)
```

## 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 mailbox 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](https://pidgeon.ai/docs/pods.md), including
  ones added later, and the pod's own domains. The key you hand one of your
  customers.
- **Every address on the account**, which only the dashboard mints.

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 key's reach 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.

## 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.

```bash
curl https://pidgeon.ai/api/v1/me -H "Authorization: Bearer $PIDGEON_API_KEY"
```

```ts
const { data } = await meGet({ throwOnError: true });

if (data.data.key.access === 'read') {
throw new Error('This key cannot send. Grant it `send`, or use another.');
}
```

```python
from pidgeon.api.me import me_get

me = me_get.sync(client=client)
print(me.data.key.access, me.data.reaches)
```

It returns the key's **ceiling**, how many addresses it **reaches**, and the
workspace. 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.

```bash
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"] }'
```

```ts
const { data: key } = await keysCreate({
body: {
  name: 'Acme',
  identity_ids: [identityId],
},
throwOnError: true,
});

// The only time you will ever see it.
await storeSomewhereSafe(key.data.token);
```

```python
httpx.post(
  "https://pidgeon.ai/api/v1/api-keys",
  headers={"Authorization": f"Bearer {os.environ['PIDGEON_API_KEY']}"},
  json={"name": "Acme", "identity_ids": [identity_id]},
)
```

Pass **one of** `identity_ids` or `pod_id`. From the dashboard, leaving the
list out means "every address this account owns" — but a field left out must
never be the widest possible answer for a credential, so over the API you say
what you mean. Only a key over the whole account may mint a pod key. Every id has to be one the calling key already reaches: 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.

## 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.

```bash
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 }'
```

```ts
const { data } = await keysRotate({
path: { id: keyId },
body: { grace_period_seconds: 3600 },
throwOnError: true,
});

// The new secret, shown once. The old one works for another hour.
await storeSomewhereSafe(data.data.token);
```

```python
from pidgeon.api.keys import keys_rotate
from pidgeon.models import KeysRotateBody

rotated = keys_rotate.sync(
  id=key_id, client=client, body=KeysRotateBody(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's `previous_token_expires_at` says 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.

```bash
npm install @pidgeon-ai/sdk
```

Every 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, 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. The same key with a *different*
request is a `409`, because that is a bug in the caller rather than a retry.
A failure is not kept, so retrying after one runs the request again. Keys
are kept for 24 hours.

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.

```bash
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" }'
```
