# Drafts

> Every drafts 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 /drafts

Every draft in one mailbox

Newest first, and including scheduled ones — a scheduled message is a draft with `send_at` set. `GET /mailboxes/drafts` lists the same rows as a mail client draws them: sender, subject, preview. This gives the recipients and the body, because a caller here is coming back to finish writing.

**Parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `identity_id` | uuid | yes |  |
| `limit` | number | no |  |

**Responses**

- `200` The drafts.
- `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 /drafts

Write one without sending it

An agent that drafts a reply, waits for a human to approve it and sends it an hour later is ordinary supervised automation, and this is where the half-written message lives in the meantime. Idempotent with `Idempotency-Key`.

**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 |
| --- | --- | --- | --- |
| `identity_id` | uuid | yes | The address this belongs to. |
| `from` | string | no | An address on a domain you own, and one this key may send from. |
| `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. |
| `subject` | string \| null | no | Left off a reply, where the thread already has one. |
| `text` | string \| null | no | The plain-text body. Send this even when you send `html` — some clients only read it. |
| `data` | object \| null | no | Typed data: `{ schema, body }`, where `schema` is a URL naming what `body` is and `body` is up to 64 KB of JSON. Sent as its own part beside the text, never mixed into it. |
| `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`. |
| `in_reply_to` | uuid \| null | no | The message being answered. Keeps the conversation together in the recipient’s client too. |
| `attachment_ids` | uuid[] | no | Ids from `POST /attachments`, uploaded before the message that carries them. Up to 20. |

**Responses**

- `201` The draft.
- `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` 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 /drafts/{id}

Fetch one draft

**Parameters**

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

**Responses**

- `200` The draft.
- `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 /drafts/{id}

Change some of it, and leave the rest

An omitted field survives; `null` clears it. Both are needed, because "I am not touching the subject" and "there is no subject" are different instructions.

**Parameters**

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

**Body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `from` | string | no | An address on a domain you own, and one this key may send from. |
| `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. |
| `subject` | string \| null | no | Left off a reply, where the thread already has one. |
| `text` | string \| null | no | The plain-text body. Send this even when you send `html` — some clients only read it. |
| `data` | object \| null | no | Typed data: `{ schema, body }`, where `schema` is a URL naming what `body` is and `body` is up to 64 KB of JSON. Sent as its own part beside the text, never mixed into it. |
| `attachment_ids` | uuid[] | no | Ids from `POST /attachments`, uploaded before the message that carries them. Up to 20. |

**Responses**

- `200` The draft.
- `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.

## DELETE /drafts/{id}

Throw it away

**Parameters**

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

**Responses**

- `200` Deleted.
- `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 /drafts/{id}/send

Send what is stored

No body: the row is the message. The browser passes the composer contents and the draft id together, because the composer *is* the draft; a caller here saved it an hour ago and may be a different process. `PATCH` first to change anything. The id that comes back is the draft's, because a send converts the row.

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

**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 /drafts/{id}/schedule

Send it later

A minute from now at the earliest, thirty days at the latest. A refused send unschedules rather than retrying forever, so a draft can come back on its own with `failed_reason` set — read that before scheduling the same message again.

**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 |
| --- | --- | --- | --- |
| `send_at` | date-time | yes |  |

**Responses**

- `200` The draft.
- `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` 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.

## DELETE /drafts/{id}/schedule

Back to being an ordinary draft

**Parameters**

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

**Responses**

- `200` The draft.
- `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.

## DELETE /drafts/{id}/attachments/{attachmentId}

Take a file off a draft

The object goes with the row. An attachment belongs to its draft rather than being shared, so leaving the bytes behind would be storage nobody can reach and everybody is billed for. `POST /attachments` is how one gets on.

**Parameters**

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

**Responses**

- `200` Deleted.
- `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.
