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 — 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_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 + 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 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