Search the docs

Find a page, a section, or an endpoint.

Addresses

The thing the product is about: an address your code owns.

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. It does not change what the address is capable of.

Typeinbox by defaultFor
humanYesSomebody reads it.
teamYesSeveral people read it.
agentYesSoftware answers, a person can step in.
applicationNoMail becomes an event. Nothing to read.
workflowNoMail drives a process you wrote.

Pass inbox explicitly to override any of them.

Create one

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

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.

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

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 reaches every address in it and nothing else; see keys 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 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 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.

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

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.

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

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_policyThe address may write to
openanybody
reply_onlysomebody who wrote to it first, plus its allow list
listonly 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.

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

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.