# Addresses

> Create an address your code owns, with or without an inbox, on the shared domain or your own. Types, capabilities and send policy explained.

An address does not have to have a person behind it.

**One field decides what an address can do, and it is `inbox`.** With one,
mail is stored and there is something to open, search and hand to a person.
Without one, mail arriving becomes an event and your webhook fires, and there
is nothing to read. That is not a limitation to work around; it is the point.
An address whose whole job is to receive purchase orders should not
accumulate a mailbox somebody has to remember to empty.

`type` is a label saying what the address is for. It sets the default for
`inbox` when you do not pass one, and it decides who a new conversation there
belongs to — see [Threads and hand-off](https://pidgeon.ai/docs/handover.md). It does not change
what the address is capable of.

| Type          | `inbox` by default | For                                     |
| ------------- | ------------------ | --------------------------------------- |
| `human`       | Yes                | Somebody reads it.                      |
| `team`        | Yes                | Several people read it.                 |
| `agent`       | Yes                | Software answers, a person can step in. |
| `application` | No                 | Mail becomes an event. Nothing to read. |
| `workflow`    | No                 | Mail drives a process you wrote.        |

Pass `inbox` explicitly to override any of them.

## Create one

```bash
curl https://pidgeon.ai/api/v1/identities -H "Authorization: Bearer $PIDGEON_API_KEY" -H "Content-Type: application/json" -d '{
  "domain_id": "your-domain-id",
  "local_part": "receipts",
  "type": "application"
}'
```

```ts
const { data } = await identitiesCreate({
body: {
  domain_id: 'your-domain-id',
  local_part: 'receipts',
  type: 'application',
},
});
```

```python
httpx.post(
  "https://pidgeon.ai/api/v1/identities",
  headers={"Authorization": f"Bearer {os.environ['PIDGEON_API_KEY']}"},
  json={
      "domain_id": "your-domain-id",
      "local_part": "receipts",
      "type": "application",
  },
)
```

## Capabilities are computed, not frozen

What an address can do is worked out from three things every time it is
asked, in this order: **what the address is** (an application has no inbox),
**what your plan allows** (API access, webhook endpoints), and **the
overrides you have set on that one address**. Only the third is stored. So an
address cannot drift out of step with the plan it is on, and upgrading does
not need a backfill.

An override cannot raise either of the first two. A free account cannot grant
itself a paid capability by editing one address — the plan is a ceiling, not
a default — and an address with no inbox cannot be given `webInbox`,
`search`, `humanTakeover` or `queue` at all. Those four are not permissions;
they describe storage, and there is none. Asking is a `400` naming the
address rather than a `200` that silently did nothing.

One capability is off everywhere until you turn it on: `readReceipts`. With
it, a Pidgeon address that writes to this one is told — as `message.read` —
when your agent first reads its message. Without it, nobody is.

That is also why a capability you do not have returns an empty result rather
than an error where the question still makes sense — asking an application
address for its threads is a reasonable question with the answer "none".

## Pods, for your own customers

A **pod** holds addresses and domains. Make one per customer of yours, per
client, or just one for test and one for production — your account starts
with a pod called Default and every address is already in it, so you can
ignore this entirely until you need it.

```bash
curl https://pidgeon.ai/api/v1/pods -H "Authorization: Bearer $PIDGEON_API_KEY" -H "Content-Type: application/json" -d '{"name": "Acme Ltd", "client_id": "cus_4417"}'
```

```ts
const { data: pod } = await podsCreate({
body: { name: 'Acme Ltd', client_id: 'cus_4417' },
});

await identitiesCreate({ body: { address: 'hello@acme.example', pod_id: pod.id } });
```

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

`client_id` is your own id for it, carried and never parsed — unique within
your account, so looking a pod up by the id your billing system already has
is one call. `GET /v1/identities?pod_id=…` lists what is in one, and
`PATCH /v1/identities/{id}` with a `pod_id` moves an address between them.

**Each pod has its own sending reputation.** Bounces and complaints from one
customer's mail are counted against that customer's pod and nothing else, so
one bad tenant cannot degrade delivery for the rest — which is the whole
reason to put your customers in separate containers rather than merely
labelling them. Mail from an address in a pod sends under that pod whichever
domain it is on, so two pods sharing one domain are still separate.

What a pod is **not**: it does not change what an address can do. A key can
be scoped to one — [a pod key](https://pidgeon.ai/docs/pods.md) reaches every address in it and
nothing else; see [keys](https://pidgeon.ai/docs/authentication.md) for the other kinds.

There is no delete. Nothing yet moves a whole pod's worth of mail, so the
verb would either refuse every pod that has anything in it or take the mail
with it. Suspend the addresses instead.

## Routing, which is not an address

An extra address that delivers into an inbox you already have — `hi@` into
`hello@` — is a routing rule rather than an identity. They are free on every
plan and do not count against your mailboxes, and a rule cannot shadow a real
address: the identity always wins for its own name.

That last part is enforced rather than implied. Creating a rule for an
address that already exists is refused, because a rule that could never fire
is a worse answer than a rejection.

This is the cheapest way to give every customer their own address. One
mailbox, one rule per tenant, and no storage per tenant to sell against.

**Routing needs a domain you connected.** Rules are per-domain, and
`pidgeon.email` — the shared domain an agent signup lands on — is owned by
nobody, so there is no catch-all and no rule on it. Every account can create
addresses there and they work for everything else; routing, DNS and deletion
are the three things that belong to an owner. [Connect one](https://pidgeon.ai/docs/domains.md)
and both arrive together. Asking anyway says so, rather than claiming the
domain does not exist — you are holding its id, and we printed it.

Per-address [rules](https://pidgeon.ai/docs/rules.md) are the thing that does work on the shared
domain: they file, block and allow by sender on an address you already have.
What they cannot do is bring a new address into being, which is the half you
need a domain for.

```bash
curl https://pidgeon.ai/api/v1/routing-rules -H "Authorization: Bearer $PIDGEON_API_KEY" -H "Content-Type: application/json" -d '{
  "match_type": "address",
  "local_part": "acme",
  "destination_identity_id": "your-identity-id"
}'
```

```ts
const rule = await routingCreate({
body: {
  match_type: 'address',
  local_part: 'acme',
  destination_identity_id: 'your-identity-id',
},
});
```

```python
httpx.post(
  "https://pidgeon.ai/api/v1/routing-rules",
  headers={"Authorization": f"Bearer {os.environ['PIDGEON_API_KEY']}"},
  json={
      "match_type": "address",
      "local_part": "acme",
      "destination_identity_id": "your-identity-id",
  },
)
```

**The destination has to be an address the key can already send from.** Every
address routed to an inbox becomes an address that inbox may use in a `From:`
header, so a rule does not only decide where mail lands — it widens what a
mailbox may send as. A key scoped to `support@` can route new addresses into
`support@` and nowhere else.

### Route to a webhook instead

A rule can point at a webhook rather than an inbox. That address has no
mailbox, so nothing is stored: the message is handed to your endpoint and
then gone. Right for `receipts@` feeding a parser, wrong for anything a
person will want to read next month. Pass `domain_id` alongside
`destination_webhook_id` — a webhook carries no domain of its own, so the
rule has to say which one it belongs to.

### Pausing rather than deleting

`PATCH /routing-rules/{id}` with `{"status": "disabled"}` stops a rule firing
and keeps it. During an incident, "where was this going" is the question you
want answerable. What a rule matches and where it points cannot be edited:
those are the rule, so replace it and delete the old one rather than leave an
update nobody can reconstruct.

### A prefix rather than one name

`match_type: "pattern"` with a prefix in `local_part` takes every address
that starts with it: `inv-` catches `inv-2026-0001@` and `inv-acme@` alike.
The prefix is literal text, never a glob or a regular expression, and at
least two characters long. When two patterns match, the longer prefix wins.
A pattern routes mail in but, unlike an address rule, is not something the
inbox can send as.

### When an address goes away

A rule cannot shadow a real address, so the order is: delete the address,
then create an `address` rule with its old local part pointing at the inbox
that takes over. Mail to the old name keeps arriving there, and that inbox may
answer from it. Deleting the address deletes its mail; if you only want it to
stop sending and receiving, suspend it instead.

### Which rule wins

An address that exists always gets its own mail. Otherwise an `address` rule,
then the longest matching `pattern`, then the catch-all: the most specific
match decides, and only one does.

### The catch-all

Everything unmatched at a domain, into one place. It takes two things, and
this catches people out: a `catch_all` rule saying where the mail goes, **and**
the switch on the domain.

```
PATCH /v1/domains/{id}   {"catch_all_enabled": true}
```

Two gates rather than one so that turning catch-all off does not throw away
the decision about where catch-all mail was going. Turning the switch on
without a rule does nothing at all.

A catch-all is a destination, not a sendable address: `*@yourdomain.com` is
not something anybody can put in a `From:` header, and routed addresses are.

## Who can reach an address

A `human` address belongs to the person it is — nobody else in the workspace
reads it, including whoever owns the workspace. Everything else is the
workspace's, and `identity_grants` records the exceptions on a ladder of
`read`, `send` and `admin`.

```bash
# Who reaches it
curl https://pidgeon.ai/api/v1/identities/316efb0a-7725-45db-a555-89c1cc0fe988/access -H "Authorization: Bearer $PIDGEON_API_KEY"

# Let a key send as it

curl https://pidgeon.ai/api/v1/identities/316efb0a-7725-45db-a555-89c1cc0fe988/access -H "Authorization: Bearer $PIDGEON_API_KEY" -H "Content-Type: application/json" -d '{ "api_key_id": "2e2d3780-d68f-49dc-aa5c-4e9a4db022a7", "access": "send" }'

# And take it away again, by the grant's own id

curl -X DELETE "https://pidgeon.ai/api/v1/identities/316efb0a-7725-45db-a555-89c1cc0fe988/access?grant_id=1d70bf4e-2c93-4a67-9f5e-8b2a1c6d0e37" -H "Authorization: Bearer $PIDGEON_API_KEY"
```

```ts
const { data } = await pidgeon.identities.access.get(
'316efb0a-7725-45db-a555-89c1cc0fe988',
);

await pidgeon.identities.access.grant('316efb0a-7725-45db-a555-89c1cc0fe988', {
api_key_id: '2e2d3780-d68f-49dc-aa5c-4e9a4db022a7',
access: 'send',
});

await pidgeon.identities.access.revoke(
'316efb0a-7725-45db-a555-89c1cc0fe988',
'1d70bf4e-2c93-4a67-9f5e-8b2a1c6d0e37',
);
```

```python
base = "https://pidgeon.ai/api/v1/identities/316efb0a-7725-45db-a555-89c1cc0fe988/access"
headers = {"Authorization": f"Bearer {os.environ['PIDGEON_API_KEY']}"}

httpx.get(base, headers=headers)

httpx.post(base, headers=headers, json={
      "api_key_id": "2e2d3780-d68f-49dc-aa5c-4e9a4db022a7",
      "access": "send",
  })

httpx.delete(base, headers=headers, params={"grant_id": "1d70bf4e-2c93-4a67-9f5e-8b2a1c6d0e37"})
```

Name exactly one of `user_id`, `api_key_id` or `everyone` when granting.
Granting **replaces** whatever that principal had, so calling it twice changes
the rung rather than leaving two answers behind; `DELETE` takes the grant's
own `id`, which `GET` returned.

Three things are worth knowing before you build on it:

- **`everyone` means the people.** A key is never one of them, however many
  grants a workspace has — a credential reaches what it was given.
- **A key can only hand out what it administers.** A key with `read` on an
  address cannot grant on it, and cannot create a key that reaches it.
- **A 403 when you can see it, a 404 when you cannot.** Short of the rung on
  an address you already read is a `403` naming the rung you have; an address
  you cannot reach at all is a `404`, because confirming it exists would be a
  way to find out what a workspace owns. This used to be a `404` either way,
  and a key that had just listed an address was then told we could not find
  it — a statement it could disprove from its own output, which teaches a
  developer that our 404s are noise.

## What an address may write to

Reach is who can use an address. This is who the address itself may send
**to**, which is the control that stops a compromised agent mailing your
customer list. It is a policy plus a list of exceptions, and the policy means
nothing without the list.

| `send_policy` | The address may write to                            |
| ------------- | --------------------------------------------------- |
| `open`        | anybody                                             |
| `reply_only`  | somebody who wrote to it first, plus its allow list |
| `list`        | only its allow list                                 |

`reply_only` is the one most agent addresses want: it can answer its own mail
and nothing else, so "what happens if the model is talked into emailing
someone" has a structural answer rather than a prompt.

A rule is an address or a domain, and the string says which — `billing@acme.com`
matches one person, `acme.com` matches everybody there.

```text
POST /v1/identities/316efb0a-7725-45db-a555-89c1cc0fe988/send-rules
{ "effect": "block", "value": "acme.com" }

POST /v1/identities/316efb0a-7725-45db-a555-89c1cc0fe988/send-rules
{ "effect": "allow", "value": "billing@acme.com" }
```

**The more specific rule wins**, so those two together do what they look like:
nobody at `acme.com` except billing. Adding a rule for a value that already
has one **replaces** it, so changing your mind is one call and never a
conflict.

Reading the rules needs `send` on the address rather than `admin`, deliberately:
a caller refused a recipient has to be able to find out why.

## Offboarding a customer

If you give each of your customers their own addresses, tag them with a
`tenant_id` — on creation or with `PATCH /v1/identities/{id}` — and one call
ends all of them. A [pod](https://pidgeon.ai/docs/pods.md) does not set one for you: an address in
a pod is reached by this call only if it carries the `tenant_id` as well.

```text
POST /v1/tenants/cus_8812/suspend

→ { "suspended": 14 }
```

**Suspends, never deletes.** The mail, the threads and the grants stay exactly
where they are, and each address emits `identity.suspended` so your own system
hears about it. A customer who comes back is not a restore from backup.

It is idempotent — it reports how many it changed, so a retry is safe — and it
reaches only addresses you could have suspended one at a time. Deleting them,
if that is what you want, is still one call each, on purpose.

## Quotas

Mailboxes and programmable addresses are counted separately, because the abuse
shapes differ. Fifty mailboxes is a small company; fifty agent addresses on a
day-old free account is somebody testing how much they can take.
