# Messages

> Every messages endpoint, generated from the API's own schemas.

Base URL `https://pidgeon.ai/api/v1`. Every request carries `Authorization: Bearer pgn_live_…`. The machine-readable version of this page is https://pidgeon.ai/openapi.json.

## GET /messages

List messages

**Parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `limit` | number | no |  |
| `cursor` | string | no |  |
| `thread_id` | uuid | yes |  |
| `body` | enum | no |  One of: `original`, `clean`. |

**Responses**

- `200` A page of messages.
- `400` The request is not valid. `error.field` names the input at fault when there is one.
- `401` No API key, or one that is not valid.
- `403` Refused. `error.code` says why: `forbidden` when this key may not do this or the account is on hold, `abuse_blocked` when a limit says no.
- `404` No such resource, or one this key cannot reach. The two answer the same, so treat a 404 as “not available to you” rather than “does not exist”.
- `409` It conflicts with what is there: it already exists, or the `etag` you sent is not the current one.
- `429` Rate limited. `X-RateLimit-Reset` says when to retry.
- `500` Something went wrong on our side. Retry; if it keeps happening, tell us.
- `503` Something this needs is not answering right now. Retry in a moment.

## POST /messages

Send a message

Idempotent with an `Idempotency-Key` header. Answers `202` with the id, the thread and anything **rejected** — addresses the provider would not accept at hand-off, so read it before assuming every recipient got it. A recipient this address is not allowed to write to is a different thing and refuses the whole send: `403` with `blocked` when a rule says no, `not_permitted` when the send policy does. Nothing is sent to anybody in that case.

**Parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | string | no | Any string unique to this attempt, reused on its retries. A repeat with the same key and the same request returns the first answer with `Idempotent-Replay: true` and does nothing — no second email. The same key with a different request is a `409`. Kept for 24 hours; a failure is not kept, so a retry after one runs. |

**Body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `from` | email | yes | An address on a domain you own, and one this key may send from. |
| `to` | email \| email[] | yes | One address or several. A string is accepted for the single case. |
| `cc` | email \| email[] | no | Copied, and visible to everybody who receives the message. |
| `bcc` | email \| email[] | no | Copied, and visible to nobody else. |
| `subject` | string | no | Left off a reply, where the thread already has one. |
| `text` | string | no | The plain-text body. Send this even when you send `html` — some clients only read it. |
| `html` | string | no | The rich body. Sanitised on the way in; scripts and remote content are removed. |
| `data` | object | no | Typed data: structured JSON sent beside the text, for software at the other end to read as fields. Travels as its own part; it is never mixed into the text. |
| `attachment_ids` | uuid[] | no | Ids from `POST /attachments`, uploaded before the message that carries them. Up to 20. |
| `in_reply_to` | uuid \| null | no | The message being answered. Keeps the conversation together in the recipient’s client too. |
| `in_reply_to_message_id` | uuid \| null | no | The same field as `in_reply_to`, under the name this endpoint has always used. Both names work on both endpoints; reach for `in_reply_to`. |

**Responses**

- `202` Accepted.
- `400` The request is not valid. `error.field` names the input at fault when there is one.
- `401` No API key, or one that is not valid.
- `403` Refused, and nothing was sent to anybody. `error.code` says why: `blocked` (a rule), `not_permitted` (the send policy), `abuse_blocked` (a sending limit) or `forbidden` (the address is not one this key can send from, or is not active).
- `404` No such resource, or one this key cannot reach. The two answer the same, so treat a 404 as “not available to you” rather than “does not exist”.
- `409` This `Idempotency-Key` was used with a different request, or that request is still running. Nothing was sent by this call.
- `429` Rate limited. `X-RateLimit-Reset` says when to retry.
- `500` Something went wrong on our side. Retry; if it keeps happening, tell us.
- `503` Something this needs is not answering right now. Retry in a moment.

## GET /messages/search

Search messages

Full-text, ranked by relevance, across every address this credential can reach. Name an `identity_id` to search exactly one. Every result carries the address it was found in and the folder it is in, because a hit you cannot attribute to a mailbox is a hit you cannot open. Not paged: page two of a relevance ranking is a place nobody goes, so narrow the query instead.

**Parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `identity_id` | uuid | no |  |
| `q` | string | no |  |
| `in` | enum | no |  One of: `inbox`, `starred`, `sent`, `drafts`, `scheduled`, `snoozed`, `archive`, `spam`, `trash`, `unauthenticated`. |
| `from` | string | no |  |
| `to` | string | no |  |
| `has_attachment` | string | no |  |
| `is_unread` | string | no |  |
| `is_starred` | string | no |  |
| `label_id` | uuid | no |  |
| `limit` | number | no |  |

**Responses**

- `200` What matched.
- `400` The request is not valid. `error.field` names the input at fault when there is one.
- `401` No API key, or one that is not valid.
- `403` Refused. `error.code` says why: `forbidden` when this key may not do this or the account is on hold, `abuse_blocked` when a limit says no.
- `404` No such resource, or one this key cannot reach. The two answer the same, so treat a 404 as “not available to you” rather than “does not exist”.
- `409` It conflicts with what is there: it already exists, or the `etag` you sent is not the current one.
- `429` Rate limited. `X-RateLimit-Reset` says when to retry.
- `500` Something went wrong on our side. Retry; if it keeps happening, tell us.
- `503` Something this needs is not answering right now. Retry in a moment.

## GET /messages/await

Wait for the next message

Holds the request open until a message matching the filters arrives at the address, or `timeout` seconds pass — one call where you would otherwise poll. The message carries `extracted`: verification codes and links, most likely first. Without `after`, it first looks back 2 minutes and returns the newest matching message from then, because a code often lands before the call is made; narrow it with `from`, `subject_contains` or `has_code` so an earlier email is not taken for the one you want, or pass `after=now` to wait only for new mail. Pass the returned `cursor` as `after` on the next call so nothing arriving between calls is missed. Answers 200 with `message: null` and `timed_out: true` when nothing came. A matching message filed as spam is not returned unless you pass `include_spam=true`; it is counted in `held_back`, with its id, so a timeout never hides mail that arrived.

**Parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `identity_id` | uuid | yes |  |
| `body` | enum | no |  One of: `original`, `clean`. |
| `from` | string | no |  |
| `subject_contains` | string | no |  |
| `has_code` | enum | no |  One of: `true`, `false`. |
| `include_spam` | enum | no |  One of: `true`, `false`. |
| `after` | string \| date-time | no |  |
| `timeout` | number | no |  |

**Responses**

- `200` The message, or that none came in time.
- `400` The request is not valid. `error.field` names the input at fault when there is one.
- `401` No API key, or one that is not valid.
- `403` Refused. `error.code` says why: `forbidden` when this key may not do this or the account is on hold, `abuse_blocked` when a limit says no.
- `404` No such resource, or one this key cannot reach. The two answer the same, so treat a 404 as “not available to you” rather than “does not exist”.
- `409` It conflicts with what is there: it already exists, or the `etag` you sent is not the current one.
- `429` Rate limited. `X-RateLimit-Reset` says when to retry.
- `500` Something went wrong on our side. Retry; if it keeps happening, tell us.
- `503` Something this needs is not answering right now. Retry in a moment.

## GET /messages/{id}

Fetch one message

**Parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `body` | enum | no |  One of: `original`, `clean`. |

**Responses**

- `200` The message.
- `400` The request is not valid. `error.field` names the input at fault when there is one.
- `401` No API key, or one that is not valid.
- `403` Refused. `error.code` says why: `forbidden` when this key may not do this or the account is on hold, `abuse_blocked` when a limit says no.
- `404` No such resource, or one this key cannot reach. The two answer the same, so treat a 404 as “not available to you” rather than “does not exist”.
- `409` It conflicts with what is there: it already exists, or the `etag` you sent is not the current one.
- `429` Rate limited. `X-RateLimit-Reset` says when to retry.
- `500` Something went wrong on our side. Retry; if it keeps happening, tell us.
- `503` Something this needs is not answering right now. Retry in a moment.

## POST /messages/actions

Apply one action to many conversations

Up to 200, the same cap the dashboard uses. Thread-scoped, like everything else that changes a conversation. The response carries an `undo`.

**Body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `message_ids` | uuid[] | yes |  |
| `action` | enum | yes | `read` and `unread` are refused for an API key: read state belongs to a person and a key is not one. Every other action works. One of: `read`, `unread`, `star`, `unstar`, `archive`, `unarchive`, `delete`, `restore`, `spam`, `not_spam`, `delete_forever`. |

**Responses**

- `200` What was applied.
- `400` The request is not valid. `error.field` names the input at fault when there is one.
- `401` No API key, or one that is not valid.
- `403` Refused. `error.code` says why: `forbidden` when this key may not do this or the account is on hold, `abuse_blocked` when a limit says no.
- `404` No such resource, or one this key cannot reach. The two answer the same, so treat a 404 as “not available to you” rather than “does not exist”.
- `409` It conflicts with what is there: it already exists, or the `etag` you sent is not the current one.
- `429` Rate limited. `X-RateLimit-Reset` says when to retry.
- `500` Something went wrong on our side. Retry; if it keeps happening, tell us.
- `503` Something this needs is not answering right now. Retry in a moment.

## POST /messages/undo

Put an action back

Hand back the `undo` from a `PATCH` or a batch. Unusual for an API to have, and the argument is that software makes mistakes at a speed a person does not.

**Body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `undo` | object[] | yes |  |

**Responses**

- `200` How many rows went back.
- `400` The request is not valid. `error.field` names the input at fault when there is one.
- `401` No API key, or one that is not valid.
- `403` Refused. `error.code` says why: `forbidden` when this key may not do this or the account is on hold, `abuse_blocked` when a limit says no.
- `404` No such resource, or one this key cannot reach. The two answer the same, so treat a 404 as “not available to you” rather than “does not exist”.
- `409` It conflicts with what is there: it already exists, or the `etag` you sent is not the current one.
- `429` Rate limited. `X-RateLimit-Reset` says when to retry.
- `500` Something went wrong on our side. Retry; if it keeps happening, tell us.
- `503` Something this needs is not answering right now. Retry in a moment.

## PATCH /messages/{id}

Change one conversation

Three different kinds of fact: `is_read` is about the reader, `is_starred` is about the mailbox, and `folder` is where the conversation lives. Thread-scoped. The response carries an `undo`.

**Parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |

**Body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `is_read` | boolean | no | Refused for an API key: read state belongs to a person and a key is not one. Archive the message, or track what you have seen with `GET /v1/mailboxes/changes`. A person signed in to the dashboard sets this. |
| `is_starred` | boolean | no |  |
| `folder` | enum | no |  One of: `inbox`, `archive`, `spam`, `trash`. |

**Responses**

- `200` What changed.
- `400` The request is not valid. `error.field` names the input at fault when there is one.
- `401` No API key, or one that is not valid.
- `403` Refused. `error.code` says why: `forbidden` when this key may not do this or the account is on hold, `abuse_blocked` when a limit says no.
- `404` No such resource, or one this key cannot reach. The two answer the same, so treat a 404 as “not available to you” rather than “does not exist”.
- `409` It conflicts with what is there: it already exists, or the `etag` you sent is not the current one.
- `429` Rate limited. `X-RateLimit-Reset` says when to retry.
- `500` Something went wrong on our side. Retry; if it keeps happening, tell us.
- `503` Something this needs is not answering right now. Retry in a moment.

## GET /messages/{id}/raw

The message as it arrived

`message/rfc822`, not JSON — the point is bytes nobody re-encoded. Received mail is the archived original while it is kept; sent mail, and received mail past its retention, is rebuilt from the stored message. `X-Pidgeon-Reconstructed` says which: a rebuild is not the signed bytes and cannot verify DKIM.

**Parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |

**Responses**

- `200` The message, original or rebuilt (`X-Pidgeon-Reconstructed`).
- `400` The request is not valid. `error.field` names the input at fault when there is one.
- `401` No API key, or one that is not valid.
- `403` Refused. `error.code` says why: `forbidden` when this key may not do this or the account is on hold, `abuse_blocked` when a limit says no.
- `404` No such resource, or one this key cannot reach. The two answer the same, so treat a 404 as “not available to you” rather than “does not exist”.
- `409` It conflicts with what is there: it already exists, or the `etag` you sent is not the current one.
- `429` Rate limited. `X-RateLimit-Reset` says when to retry.
- `500` Something went wrong on our side. Retry; if it keeps happening, tell us.
- `503` Something this needs is not answering right now. Retry in a moment.

## POST /messages/{id}/reply

Reply to one message

The recipients, the subject and the thread are read off the message being answered. You bring the words.

**Parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `Idempotency-Key` | string | no | Any string unique to this attempt, reused on its retries. A repeat with the same key and the same request returns the first answer with `Idempotent-Replay: true` and does nothing — no second email. The same key with a different request is a `409`. Kept for 24 hours; a failure is not kept, so a retry after one runs. |

**Body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `text` | string | no | The plain-text body. Send this even when you send `html` — some clients only read it. |
| `html` | string | no | The rich body. Sanitised on the way in; scripts and remote content are removed. |
| `data` | object | no | Typed data: structured JSON sent beside the text, for software at the other end to read as fields. Travels as its own part; it is never mixed into the text. |
| `to` | email[] | no | One address or several. A string is accepted for the single case. |
| `cc` | email[] | no | Copied, and visible to everybody who receives the message. |
| `bcc` | email[] | no | Copied, and visible to nobody else. |
| `attachment_ids` | uuid[] | no | Ids from `POST /attachments`, uploaded before the message that carries them. Up to 20. |

**Responses**

- `202` Accepted.
- `400` The request is not valid. `error.field` names the input at fault when there is one.
- `401` No API key, or one that is not valid.
- `403` Refused, and nothing was sent to anybody. `error.code` says why: `blocked` (a rule), `not_permitted` (the send policy), `abuse_blocked` (a sending limit) or `forbidden` (the address is not one this key can send from, or is not active).
- `404` No such resource, or one this key cannot reach. The two answer the same, so treat a 404 as “not available to you” rather than “does not exist”.
- `409` This `Idempotency-Key` was used with a different request, or that request is still running. Nothing was sent by this call.
- `429` Rate limited. `X-RateLimit-Reset` says when to retry.
- `500` Something went wrong on our side. Retry; if it keeps happening, tell us.
- `503` Something this needs is not answering right now. Retry in a moment.

## POST /messages/{id}/reply-all

Reply to everybody on one message

Every address on the message except the ones that reach this mailbox — which a caller cannot work out, because it needs the routing table.

**Parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `Idempotency-Key` | string | no | Any string unique to this attempt, reused on its retries. A repeat with the same key and the same request returns the first answer with `Idempotent-Replay: true` and does nothing — no second email. The same key with a different request is a `409`. Kept for 24 hours; a failure is not kept, so a retry after one runs. |

**Body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `text` | string | no | The plain-text body. Send this even when you send `html` — some clients only read it. |
| `html` | string | no | The rich body. Sanitised on the way in; scripts and remote content are removed. |
| `data` | object | no | Typed data: structured JSON sent beside the text, for software at the other end to read as fields. Travels as its own part; it is never mixed into the text. |
| `to` | email[] | no | One address or several. A string is accepted for the single case. |
| `cc` | email[] | no | Copied, and visible to everybody who receives the message. |
| `bcc` | email[] | no | Copied, and visible to nobody else. |
| `attachment_ids` | uuid[] | no | Ids from `POST /attachments`, uploaded before the message that carries them. Up to 20. |

**Responses**

- `202` Accepted.
- `400` The request is not valid. `error.field` names the input at fault when there is one.
- `401` No API key, or one that is not valid.
- `403` Refused, and nothing was sent to anybody. `error.code` says why: `blocked` (a rule), `not_permitted` (the send policy), `abuse_blocked` (a sending limit) or `forbidden` (the address is not one this key can send from, or is not active).
- `404` No such resource, or one this key cannot reach. The two answer the same, so treat a 404 as “not available to you” rather than “does not exist”.
- `409` This `Idempotency-Key` was used with a different request, or that request is still running. Nothing was sent by this call.
- `429` Rate limited. `X-RateLimit-Reset` says when to retry.
- `500` Something went wrong on our side. Retry; if it keeps happening, tell us.
- `503` Something this needs is not answering right now. Retry in a moment.

## POST /messages/{id}/forward

Forward one message

`to` is required: a forward exists to reach somebody the original did not.

**Parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `Idempotency-Key` | string | no | Any string unique to this attempt, reused on its retries. A repeat with the same key and the same request returns the first answer with `Idempotent-Replay: true` and does nothing — no second email. The same key with a different request is a `409`. Kept for 24 hours; a failure is not kept, so a retry after one runs. |

**Body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `text` | string | no | The plain-text body. Send this even when you send `html` — some clients only read it. |
| `html` | string | no | The rich body. Sanitised on the way in; scripts and remote content are removed. |
| `data` | object | no | Typed data: structured JSON sent beside the text, for software at the other end to read as fields. Travels as its own part; it is never mixed into the text. |
| `to` | email[] | no | One address or several. A string is accepted for the single case. |
| `cc` | email[] | no | Copied, and visible to everybody who receives the message. |
| `bcc` | email[] | no | Copied, and visible to nobody else. |
| `attachment_ids` | uuid[] | no | Ids from `POST /attachments`, uploaded before the message that carries them. Up to 20. |

**Responses**

- `202` Accepted.
- `400` The request is not valid. `error.field` names the input at fault when there is one.
- `401` No API key, or one that is not valid.
- `403` Refused, and nothing was sent to anybody. `error.code` says why: `blocked` (a rule), `not_permitted` (the send policy), `abuse_blocked` (a sending limit) or `forbidden` (the address is not one this key can send from, or is not active).
- `404` No such resource, or one this key cannot reach. The two answer the same, so treat a 404 as “not available to you” rather than “does not exist”.
- `409` This `Idempotency-Key` was used with a different request, or that request is still running. Nothing was sent by this call.
- `429` Rate limited. `X-RateLimit-Reset` says when to retry.
- `500` Something went wrong on our side. Retry; if it keeps happening, tell us.
- `503` Something this needs is not answering right now. Retry in a moment.

## POST /messages/{id}/invitation/respond

Accept, decline or tentatively accept a meeting

The one call here that changes something in somebody else's calendar: an iCalendar `REPLY` goes to the organiser, which is how Google, Outlook and Calendly learn about an acceptance. The message must be carrying an `invitation`, and one of this mailbox's own addresses must be on it — including an address routed to it, which is the half you cannot work out from the message. Idempotent on the answer: the same answer to the same `sequence` sends once and returns `already_sent`.

**Parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `Idempotency-Key` | string | no | Any string unique to this attempt, reused on its retries. A repeat with the same key and the same request returns the first answer with `Idempotent-Replay: true` and does nothing — no second email. The same key with a different request is a `409`. Kept for 24 hours; a failure is not kept, so a retry after one runs. |

**Body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `status` | enum | yes |  One of: `accepted`, `declined`, `tentative`. |
| `comment` | string | no |  |

**Responses**

- `200` Already answered.
- `202` Sent.
- `400` The request is not valid. `error.field` names the input at fault when there is one.
- `401` No API key, or one that is not valid.
- `403` Refused, and nothing was sent to anybody. `error.code` says why: `blocked` (a rule), `not_permitted` (the send policy), `abuse_blocked` (a sending limit) or `forbidden` (the address is not one this key can send from, or is not active).
- `404` No such resource, or one this key cannot reach. The two answer the same, so treat a 404 as “not available to you” rather than “does not exist”.
- `409` This `Idempotency-Key` was used with a different request, or that request is still running. Nothing was sent by this call.
- `429` Rate limited. `X-RateLimit-Reset` says when to retry.
- `500` Something went wrong on our side. Retry; if it keeps happening, tell us.
- `503` Something this needs is not answering right now. Retry in a moment.
