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
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:
{ "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) 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:
{ "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.
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 only. No HTML body and no attachments via this endpoint.
- 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.