View as markdown ↓

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_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:

{ "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:

{ "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 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.