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.
| 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
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:
everyonemeans 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
readon 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
403naming the rung you have; an address you cannot reach at all is a404, because confirming it exists would be a way to find out what a workspace owns. This used to be a404either 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.
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.