---
title: Sending & idempotency
description: POST /api/v1/send, the single-recipient rule, client_id duplicate protection with the full replay table, and provider-unknown semantics.
---

# Sending & idempotency

`POST /api/v1/send` is the single outbound path for direct and MCP sends. It authenticates
with an `api` or `mcp` key that has `can_send`.

## Request

```bash
curl -X POST https://your-app.example.com/api/v1/send \
  -H "Authorization: Bearer pf_..." \
  -H "Content-Type: application/json" \
  -d '{
    "mailbox_id": "b1a2...",
    "to": "person@example.com",
    "subject": "Subject line",
    "text": "Plain-text body.",
    "client_id": "my-unique-op-123"
  }'
```

| Field | Required | Notes |
|---|---|---|
| `mailbox_id` | yes | Must be a mailbox in your account (and, for a bound key, *your* mailbox). |
| `text` | yes | Plain-text body. |
| `to` | conditionally | Required unless `reply_to_message_id` is set. |
| `subject` | no | Ignored on replies (derived as `Re: …`). |
| `reply_to_message_id` | no | Threads the reply; derives `to` and `subject` from the original. |
| `client_id` | no | 1–256 chars. Enables idempotency. Must not start with `draft:`. |

Success returns `201`:

```json
{ "id": "m_123...", "thread_id": "t_456...", "provider_message_id": "prov_789..." }
```

### Validation errors (`400`)

- Missing `mailbox_id` or `text` → `{"error": "mailbox_id and text are required"}`
- `client_id` not a 1–256-char string → `{"error": "client_id must be a string of 1-256 characters"}`
- `client_id` starting with `draft:` → `{"error": "client_id must not start with \"draft:\""}`
  (that prefix is reserved for the internal draft-send namespace)

## The single-recipient rule

**`to` must resolve to exactly one bare email address.** There is no `cc`, no `bcc`, and no
multi-recipient list. A value carrying more than one address (via a comma, semicolon, or
whitespace) is rejected:

```json
{ "error": "to must be a single email address" }
```

A `Display Name <addr@example.com>` form is unwrapped to the bare address first. This is a
security invariant: the address the policy check sees is exactly the address the provider
sends to, so a smuggled second recipient can never slip past the send-list check.

If you provide neither `to` nor a reply context: `400 {"error": "to is required unless replying"}`.

## Replying

With `reply_to_message_id`, `to`/`subject`/threading are derived from the original message:

- Original not found → `404 {"error": "original message not found"}`
- Original in a **different mailbox**: a mailbox-bound key gets `404`
  (`original message not found`, anti-enumeration); an account-wide key gets
  `400 {"error": "original message belongs to a different mailbox"}`

## Duplicate protection (`client_id`) and the replay table

Without a `client_id`, every request delivers — there is no de-duplication. With a
`client_id`, the send is **idempotent per `(mailbox_id, client_id)`**. The first request
claims the operation; a retried request replays the stored outcome rather than sending again.

The replay decision compares a hash of the request payload
(`mailbox_id` + resolved recipient + `subject` + `text` + `in_reply_to`) against the stored
operation:

| Retry condition | Status | Body |
|---|---|---|
| Same `client_id`, same body, original already **sent** | `200` | The stored send result, replayed verbatim |
| Same `client_id`, **different** body | `409` | `{"code": "idempotency_conflict", "error": "client_id was already used with a different request payload"}` |
| Same `client_id`, original still **in progress** | `409` | `{"code": "idempotency_in_progress", "error": "a send with this client_id is still in progress; retry shortly"}` |
| Same `client_id`, original was a **forced draft** | `202` | The stored `{"draft_id": "...", "status": "pending_approval"}` |
| Same `client_id`, original was **rejected/failed** | stored status | The stored terminal result |

Note the status split: a **first** successful send returns `201`; a **replay** of an
already-sent operation returns `200`. Both carry the same `{id, thread_id, provider_message_id}`.

## Quota (`402`)

When the account's monthly send quota is exhausted:

```json
{ "error": "monthly send quota exhausted" }
```

with status `402`. This outcome is **terminal for that `client_id`** — retrying with the same
`client_id` replays the `402`. After an upgrade, retry with a **new** `client_id`.

## Suppressed recipients (`422`)

A recipient that previously bounced, complained, or unsubscribed is suppressed:

```json
{ "error": "recipient is suppressed (bounce/complaint/unsubscribe)" }
```

with status `422`.

## Provider-`unknown` outcome (`502`)

If the email provider call fails in a way where the send **may or may not have gone out**
(a fetch-level exception), a keyed send returns:

```json
{ "error": "...", "retryable": false, "code": "delivery_outcome_unknown" }
```

with status `502`. On this path Postfleet **does not** finalize the operation and **does not**
release quota — the send is held for server-side reconciliation so a naive retry can't
duplicate a message that actually left. Same-`client_id` replays keep returning
`409 idempotency_in_progress` until reconciliation resolves it. (An unkeyed send that hits an
unknown outcome also returns `502` and refunds the quota, but offers no replay protection.)

Ordinary transient provider errors return `502 {"error": "...", "retryable": true}`; permanent
provider rejections return `400 {"error": "...", "retryable": false}`. Both are terminal for
the `client_id`.

## Current limitations

- **Single recipient only.** No `cc`, `bcc`, or multi-recipient sends. Send to each recipient
  separately (each with its own `client_id`).
- **Plain text only.** No HTML body and no attachments via this endpoint.
- **Terminal outcomes are sticky to the `client_id`.** A `402` quota result, a `400` permanent
  rejection, and a `502` transient failure all replay for the same `client_id`; a genuine retry
  requires a new `client_id`.
- **A provider-`unknown` (`502 delivery_outcome_unknown`) operation is reconciled
  server-side**, not by the client. There is no API to force-resolve it; same-key replays get
  `409 idempotency_in_progress` until reconciliation completes.
