---
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.
updatedAt: "2026-10-13"
---

# Sending & idempotency

`POST /api/v1/send` is the single outbound path for direct and [MCP](/docs/mcp) sends. It
authenticates with an `api` or `mcp` key that has `can_send` — see
[authentication & key scoping](/docs/auth). If you have not sent anything yet, the
[quickstart](/docs/quickstart) does this end to end in about five minutes.

## 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:`. |
| `attachments` | no | Up to 20 files. Each item is exactly one of `{content}` (standard base64) or `{url}` (HTTPS, fetched at send time). `filename` and `content_type` are optional — a URL's filename is taken from the path, otherwise `attachment`. 6 MB total inline, 30 MB total URL-backed. Use `url` for anything that would blow the request body. |

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` + attachment
digests when files are present) 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](/docs/drafts)** | `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
{ "code": "monthly_send_limit", "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`.

## Automatic sending holds (`403`)

Postfleet screens outbound activity before contacting the email provider. Sending is held across
every mailbox in your account when it exceeds **20 admitted attempts in a minute** or **100 in an
hour**, receives a spam complaint, or receives five distinct hard bounces within an hour. These
sliding-window limits apply on every plan, separately from monthly quotas. Provider failures count
toward the limits so retry loops stop too; draft creation and completed idempotent replays do not.
The existing free-account distinct-recipient and daily-send limits still apply and return `429`.
The existing daily bounce-rate rule also remains: three bounces, at least ten sends, and a bounce
rate of 10% or more within a rolling day can hold sending before the hourly threshold is reached.

```json
{
  "code": "sending_held",
  "error": "Sending is on hold. The account owner must review it in the dashboard.",
  "reason": "burst_limit",
  "hold_id": "10000000-0000-4000-8000-000000000001",
  "retryable": false
}
```

Other reasons are `hourly_limit`, `spam_complaint`, `hard_bounces`, `owner_pause`, `bounce_rate`,
`manual_review`, and `legacy_hold`. Only the
signed-in owner can resume sending in **Dashboard → Mailboxes**. Switching keys or mailboxes and
waiting for a window to expire do not clear a hold. Receiving mail continues. Review the agent's
activity and fix its workflow before resuming; suppressed recipients remain blocked.

Held mail is never delivered and does not consume monthly quota. Approved and voluntary drafts
use this same gate. Resuming does not automatically send queued drafts. A hold rejection is
terminal for its `client_id`: a deliberate retry after review requires a fresh ID.
Existing account holds keep returning `403 account_send_held`; those release the operation claim,
so you may reuse the same ID after review. They also appear in the dashboard and can be resumed
there. Daily bounce history is retained after resuming, so new adverse feedback may reapply a hold.

If screening is unavailable, sending fails closed with `503`,
`code: "sending_safety_unavailable"`, and `retryable: true`. No email is delivered. A keyed
rejection is still terminal for its `client_id`, so use a fresh ID after the service recovers.

If the blocked send's quota refund needs recovery, the response is `503` with
`code: "sending_cleanup_pending"`. No email was delivered. Retry **the same** `client_id` to
retry cleanup, or let the scheduled cleanup sweep finish it. After cleanup, the original rejection
is replayed and quota is refunded once. Known-undelivered drafts return to their original queue
even while cleanup is pending; recovering an older attempt never changes a newer draft send.

These controls limit damage; they cannot guarantee domain reputation or prevent a provider
suspension. Already admitted sends can finish, feedback arrives after delivery, and low-volume
abuse may not trigger a rate hold. This is activity and policy screening, rather than outbound
content classification.

## 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 body.** No HTML body. Attachments are supported (base64 `content` or HTTPS `url`, see table above). URL-backed files are fetched at send time with the same SSRF pin as webhooks (HTTPS only, no redirects) — the URL is never forwarded to the provider.
- **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.

## Where to next

- [Quickstart](/docs/quickstart) — first authenticated send, start to finish
- [Drafts & human-in-the-loop](/docs/drafts) — when a send should become an approval instead
- [API reference](/docs/api) — the full request/response schema for this endpoint
- [MCP setup](/docs/mcp) — `send_email` and `reply_email` are this endpoint, with `client_id` required
