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 — see
authentication & key scoping. If you have not sent anything yet, the
quickstart does this end to end in about five minutes.
Request
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:
{ "id": "m_123...", "thread_id": "t_456...", "provider_message_id": "prov_789..." }
Validation errors (400)
- Missing
mailbox_idortext→{"error": "mailbox_id and text are required"} client_idnot a 1–256-char string →{"error": "client_id must be a string of 1-256 characters"}client_idstarting withdraft:→{"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:
{ "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 gets400 {"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 |
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:
{ "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:
{ "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.
{
"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:
{ "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 ownclient_id). - Plain text body. No HTML body. Attachments are supported (base64
contentor HTTPSurl, 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. A402quota result, a400permanent rejection, and a502transient failure all replay for the sameclient_id; a genuine retry requires a newclient_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 get409 idempotency_in_progressuntil reconciliation completes.
Where to next
- Quickstart — first authenticated send, start to finish
- Drafts & human-in-the-loop — when a send should become an approval instead
- API reference — the full request/response schema for this endpoint
- MCP setup —
send_emailandreply_emailare this endpoint, withclient_idrequired