Search the docs

Find a page, a section, or an endpoint.

Mailboxes and folders

Where mail is, and how to stay in step with it.

Every address with an inbox has one. A mailbox is folders with counts and a page of conversations in each — the shape a person sees, read and written by the API rather than only by the client.

The folders

Ten: inbox, starred, sent, drafts, scheduled, snoozed, archive, spam, trash and unauthenticated. identity_id is required — there is no merged view across addresses here, because two folders joined together are a folder nobody has. For a list of conversations across every address, use GET /threads, which is workspace-wide and whose rows now carry the folder, the sender and a preview.

Four of them are destinations. folder on a conversation may be set to inbox, archive, spam or trash. The rest are consequences of something: starred is a flag, sent and drafts are what the message is, and scheduled and snoozed hold a conversation until a time arrives. Snoozing and scheduling are dashboard actions and have no API of their own — you can read both folders, and a conversation leaves them when its time comes rather than because you moved it.

Spam is never in inbox, and the inbox page says how much there is. It carries held_back: { spam, how_to_include }, where spam is the number of unread messages filed there. An empty inbox with spam above zero means the mail came and the classifier distrusted it: read spam next.

unauthenticated is a view over the other nine, not a tenth place. Mail in it says it is from somebody and failed to prove it — SPF, DKIM or DMARC — and the same message is also in Inbox or Spam, wherever the classifier put it. Nothing moves anything in or out. Read authentication on the message for what the sending domain's own records actually said.

curl "https://pidgeon.ai/api/v1/mailboxes?identity_id=0c939bd7-2d69-4150-a2ac-de994f0e4887" \
-H "Authorization: Bearer $PIDGEON_API_KEY"

total and unread are null rather than 0 where the product does not count that folder. Zero would be a claim; null says we do not count it.

One folder, a page at a time

A row is a conversation — the newest message in each thread — not a message. It carries no body: a list of forty conversations does not want forty bodies. GET /messages?thread_id= opens one.

curl "https://pidgeon.ai/api/v1/mailboxes/inbox?identity_id=0c939bd7-2d69-4150-a2ac-de994f0e4887&limit=50" \
-H "Authorization: Bearer $PIDGEON_API_KEY"

cursor is opaque, and it is not an offset. It carries a sort key and an id together, because several messages can arrive in the same millisecond and a cursor on the timestamp alone repeats or drops rows at a page boundary. An offset would be worse than either: rows shift underneath it and a client syncing an inbox silently loses mail. Pass back what next_cursor gave you; do not construct one.

A cursor we did not write is page one, not an error. It arrives in a URL, and a URL is a thing people edit.

Staying in step

An agent that watches a mailbox goes away and comes back, and the question it has is not "what is in the inbox" but "what has happened since I last looked". Paging the folder and diffing it against what you remember is the answer without this endpoint, and it is not one anybody should have to write.

curl "https://pidgeon.ai/api/v1/mailboxes/changes?identity_id=0c939bd7-2d69-4150-a2ac-de994f0e4887&since=$CURSOR" \
-H "Authorization: Bearer $PIDGEON_API_KEY"

The first cursor comes off a listing, not from this endpoint, and sync_cursor sits beside data on every page of GET /mailboxes/{folder}. It is not a page cursor: the two are different reads and each refuses the other, with a sentence saying which you handed it.

Per mailbox, not per folder. A message moving from the inbox to the archive is one event; asking per folder would deliver it as a disappearance in one poll and an appearance in another, and would mean nine polls where one does. Each row carries folder, so you learn where it went.

Arrivals are in it. New mail is the most important thing that happens to a mailbox, and a client polling the listing for arrivals and this for changes would be polling twice for one job.

A deletion carries the id and nothing else. There is nothing else left — the message is gone, and keeping a record of what it said would make "delete" mean "delete most of it".

since is not a page cursor. Both are opaque, and they walk different orders: cursor pages a folder newest-first, since walks change time. Passing one where the other belongs is refused rather than answered, because the wrong answer would be a convincing one.

410 resync means start again. We keep the record of what was deleted for thirty days — the same window raw messages get, because a record that a message was deleted should not outlive the message. A cursor older than that cannot be answered completely, and an empty delta would be indistinguishable from "nothing changed".

Taking all of it with you

GET /v1/export streams every message your key can read as one mbox file — the format Apple Mail, Thunderbird and most archivers import. Pass identity_id for one address. Each message carries an X-Pidgeon-Mailbox header, so a file with several addresses in it can be split again, and originals come back byte for byte while storage still holds them. Four exports an hour per workspace.

cURL
curl https://pidgeon.ai/api/v1/export \
-H "Authorization: Bearer $PIDGEON_API_KEY" \
-o pidgeon.mbox