Drafts API
A draft is a message in state='draft'. It lives in the same store as any
other message, under the same authentication and tenant isolation, and moves
through a deliberate scan-then-review-then-send flow:
- Create / update run the scanner (and PII detection) synchronously, so a verdict is attached to the row before you commit to sending.
- Send re-runs the scanner authoritatively — policy and engine may have changed since create — plus the full send-time gate stack. The create-time verdict is a preview; the send-time verdict is authority.
Drafts are excluded from the default message list; pass ?status=draft to a
message-list read to opt them back in, or use the dedicated endpoints below.
This page is the human reference for the drafts resource. The machine-readable contract for every operation ships as an OpenAPI document at /docs/openapi.json.
Authentication
Every operation accepts one of:
- Bearer admin key — full account access.
- Bearer agent key — must be scoped to the draft's mailbox. A key bound to a
different mailbox sees
404 NOT_FOUNDon reads and is rejected on writes. - Session cookie — the dashboard.
One operation is stricter: POST /v1/drafts/:id/release-and-send is
admin key or session only — agent keys get 403 INSUFFICIENT_SCOPE. This is
noted again on that operation.
Operation classification
Each operation below carries a classification tag:
| Tag | Meaning |
|---|---|
| read-only | Returns data; no state change, no delivery. |
| mutating | Creates or changes a draft row; never puts email on the wire. |
| send-triggering | Can dispatch an email (subject to the send-time gates). |
| human-review-possible | May route to human review (pending_review) instead of sending. |
For the full error-code catalog and remediation, see
/agents/errors. For the message state machine and the
scan.verdict / worst_decision vocabulary, see
/agents/messages. For why a send is held or blocked at the
gate stack, see /agents/send-gates.
Create a draft — POST /v1/drafts
Classification: mutating. Creating a draft never sends email; the scanner runs to produce a preview verdict on the row.
Auth: admin key, session, or agent key bound to the target mailbox.
Request
{
"mailbox_id": "support",
"to": "[email protected]",
"subject": "Re: your invoice",
"body": "Thanks for your question.",
"html": "<p>Thanks for your question.</p>",
"in_reply_to_message_id": "uuid-of-original-message",
"thread_id": "thread-uuid",
"send_at": "2026-05-05T16:00:00Z",
"attachment_ids": ["att-handle-uuid"]
}mailbox_id— a mailbox name or UUID.to,subject,body— the recipient, subject, and text body.subjectis CRLF-stripped;bodyis length-capped (same validation as an immediate send).html(optional) — an HTML body. It is run through the outbound HTML sanitization gate at create time: active/unsafe constructs (scripts, forms, event handlers, and similar) are rejected with400 OUTBOUND_HTML_ACTIVE_CONTENT_REJECTED(details.categoriesnames what was found), and a body that fails to parse or exceeds 1,000,000 bytes fails closed with400 OUTBOUND_HTML_SANITIZE_FAILED— both before any storage write. The frozen sanitized deliverable is persisted, but (unlike the immediate send and reply endpoints) the draft response does not echohtml_sanitized/removed_categories.in_reply_to_message_id(optional) — reply to a received message. Derivesthread_idandin_reply_to, and auto-prefixesRe:on the subject if absent. The original must belong to the same account and mailbox. Replying to an outbound message is rejected (400 VALIDATION_ERROR) — continue an outbound conversation with a thread-mode send instead.thread_id(optional) — continue an existing thread. When present,mailbox_idandsubjectare derived from the thread andtois an optional participant selector. Mutually exclusive within_reply_to_message_id. The resolvedthread_idis persisted; the recipient gate is re-checked authoritatively at send time (see /agents/send-gates).send_at(optional) — schedule the draft for future dispatch. ISO-8601 with an explicit offset (Zor±HH:MM); a naive timestamp is rejected with400 TIMEZONE_REQUIRED, under 60 seconds out withSEND_AT_TOO_SOON, and beyond the scheduling horizon withSEND_AT_TOO_FAR. See the scheduled-send guide.attachment_ids(optional) — handles staged via the attachments API, held at create time and consumed at dispatch. Each must be scoped to this draft's mailbox. A draft cannot be both scheduled and attachment-bearing —send_at+attachment_idstogether is rejected with400 ATTACHMENTS_REQUIRE_SYNC_SEND. For the staging lifecycle, caps, and resolver error set, see /agents/attachments.
Request headers
Idempotency-Key(optional) — a stable, client-chosen key for retry-safe draft creation. A same-key retry replays the prior draft while it is still a draft; if that draft was since sent or deleted, the replay returns409 IDEMPOTENCY_KEY_DRAFT_CONSUMED. The key is namespaced to drafts: reusing a key already spent on an immediate send/reply returns409 IDEMPOTENCY_KEY_BOUND_TO_IMMEDIATE_SEND. Keys are permanent (no expiry).
Response 201
{
"id": "uuid",
"mailbox_id": "uuid",
"mailbox_name": "support",
"state": "draft",
"sender": "[email protected]",
"recipient": "[email protected]",
"subject": "Re: your invoice",
"body": {
"format": "text",
"content": "Thanks for your question.",
"char_count": 25,
"returned_char_count": 25,
"truncated": false
},
"scan": { "verdict": "clean", "categories": [], "findings": [] },
"worst_decision": "allow",
"thread_id": "original-uuid",
"in_reply_to": "<[email protected]>",
"subaddress_instance_id": null,
"subaddress_mode": null,
"send_at": "2026-05-05T16:00:00.000Z",
"original_send_at": "2026-05-05T16:00:00.000Z",
"send_attempts": 0,
"last_dispatch_error_code": null,
"last_dispatch_attempt_at": null,
"compose_held_at": null,
"compose_held_decision": null,
"releasable": false,
"created_at": "2026-04-17T00:00:00Z",
"updated_at": "2026-04-17T00:00:00Z",
"attachments": []
}scanis the create-time preview verdict (aScanSummary:verdict+categories[]+findings[]).worst_decisionis the aggregated decision. See /agents/messages for the vocabulary.mailbox_nameis the owning mailbox's label, so a caller holding a baremailbox_idUUID can read it directly.attachments[]is a safe, read-only view of the held handles —filename,content_type,size,upload_id, andpolicy_actiononly (no storage pointers or malware signatures).
Errors (important)
400 validation, TIMEZONE_REQUIRED, SEND_AT_TOO_SOON, SEND_AT_TOO_FAR,
OUTBOUND_HTML_ACTIVE_CONTENT_REJECTED, OUTBOUND_HTML_SANITIZE_FAILED,
ATTACHMENTS_REQUIRE_SYNC_SEND · 403 mailbox access denied, account suspended,
STORAGE_QUOTA_EXCEEDED · 404 mailbox or original message not found · 409
IDEMPOTENCY_KEY_BOUND_TO_IMMEDIATE_SEND, IDEMPOTENCY_KEY_DRAFT_CONSUMED ·
429 SCHEDULED_SEND_QUOTA_EXCEEDED (too many pending scheduled drafts). Full
catalog: /agents/errors.
Debounce auto-save. A client that recreates or patches a draft on every keystroke runs a scan + PII detection per request. Debounce edits (for example, 500 ms after the last keystroke) rather than firing per character.
Get a draft — GET /v1/drafts/:id
Classification: read-only.
Auth: admin key, session, or agent key bound to the draft's mailbox (a key
bound to a different mailbox sees 404 NOT_FOUND).
Applies the mailbox's PII mode at read time — a redacted mailbox returns
plaintext with <TYPE> tags in place of detected PII.
Response 200: the same shape as the create response. Returns 404 if the
draft was deleted or already sent.
List drafts — GET /v1/mailboxes/:id/drafts
Classification: read-only.
Auth: admin key, session, or agent key bound to the mailbox.
Query params
limit— default 50, max 200.before— a UUID cursor (rows created before this one).
Response 200
{ "drafts": [ /* DraftSummary[] */ ], "has_more": true }Each summary carries id, mailbox_id, mailbox_name, state, sender,
recipient, subject, worst_decision, send_at, original_send_at,
send_attempts, last_dispatch_error_code, last_dispatch_attempt_at,
created_at, updated_at. The scheduling fields are null on drafts that were
never scheduled.
has_more is true iff at least one more draft exists beyond the returned page.
Use it — not drafts.length === limit — as the pagination terminator: a page with
exactly limit rows and nothing beyond it reports has_more: false.
last_dispatch_error_codeis the raw error code thrown by a failed send attempt (e.g.RECIPIENT_NOT_ON_ALLOWLIST,DRAFT_REJECTED_BY_RESCAN) — free text for diagnosis, not a closed enum. The normalized reason code lives on themessage.dispatch_failedwebhook (see /agents/webhooks).
Update a draft — PATCH /v1/drafts/:id
Classification: mutating. A patch re-runs the scanner + PII detection and refreshes the cached verdict and body; it never sends.
Auth: admin key, session, or agent key bound to the draft's mailbox.
Request
Any non-empty subset of { to, subject, body, html, send_at, subaddress_instance_id, subaddress_mode, attachment_ids }. At least one field is
required (an empty patch is 400).
html is re-sanitized on patch, with the same OUTBOUND_HTML_ACTIVE_CONTENT_REJECTED
/ OUTBOUND_HTML_SANITIZE_FAILED codes as create. Several fields are three-way
sigils — an explicit value sets, an explicit null clears, and omission leaves
the field unchanged:
attachment_ids(string[] | null) — an array replaces the held attachment set;nullclears it; omission leaves it as-is. Handles are held now and consumed at dispatch. Settingsend_aton an attachment-bearing draft (or adding attachments to a scheduled draft) is rejected with400 ATTACHMENTS_REQUIRE_SYNC_SEND. See /agents/attachments.send_at(string | null) — a string sets or reschedules (same validation as create; a reschedule resetssend_attemptsto 0);nullcancels the schedule and clears the scheduling fields; omission leaves it as-is. See the scheduled-send guide.subaddress_instance_id/subaddress_mode(string | null) — set or clear the per-send sub-address routing hint. See the sub-addressing guide.
Response 200: the updated draft (same shape as create).
Errors (important)
400 empty patch, validation, the two OUTBOUND_HTML_* codes, the scheduling
codes · 403 STORAGE_QUOTA_EXCEEDED · 404 draft not found · 429
SCHEDULED_SEND_QUOTA_EXCEEDED (on a null→set transition). Full catalog:
/agents/errors.
Send a draft — POST /v1/drafts/:id/send
Classification: send-triggering, human-review-possible.
Auth: admin key, session, or agent key bound to the draft's mailbox.
Re-runs the scanner authoritatively against the mailbox's current policy, then
runs the full send-time gate stack (suppression, recipient policy, reply-loop,
budget, content-similarity, domain health, account/mailbox status, and — where
outbound delivery validation is enforced — recipient MX). The recipient gate
re-runs with the draft's persisted thread_id, so a thread that lost its inbound
participant (or a mailbox whose thread-reply bypass was turned off) fails closed.
For the decision tree, see /agents/send-gates.
Request: empty body ({}). The call is idempotent — a duplicate call on an
already-sent draft returns 409 DRAFT_ALREADY_SENT (details.current_state names
what it became).
Response 200
{
"message_id": "uuid",
"status": "sent",
"warning": null,
"daily_limit": 500,
"sends_remaining": 499,
"scan": { "verdict": "clean", "categories": [], "findings": [] },
"hold_context": null
}statusissenton success. On a mailbox configured to route all outbound through human review, it is insteadpending_review— the draft moves tostate='pending_review'and awaits an approve/deny decision, and the daily budget is reserved (not double-charged) at that point.scanis the send-time scanner verdict.hold_contextis the policy / human-review reason, and is non-null only when the deliverystatusdiverges fromscan.verdictbecause a mailbox review policy promoted or demoted the scanner's decision —{ trigger_source, summary_reasons[] }, elsenull.- A send-time content hold does not return
200— a scannerblockorquarantinereturns409 DRAFT_REJECTED_BY_RESCAN(below).
Asynchronous send (optimistic-ack) & polling
This is the only asynchronous send path (the immediate send/reply endpoints are always synchronous and ignore the header). The async branch is deployment-config-gated, so detect it from the response — do not assume it was taken:
-
Send the draft with the request header
Prefer: respond-async. -
Branch on the status code:
-
202with response headerPreference-Applied: respond-async— the async branch ran. The body is minimal (no verdict yet; the scan and any human-review decision run later in the dispatch consumer):{ "message_id": "uuid", "status": "queued_for_dispatch", "daily_limit": 500, "sends_remaining": 487 } -
200— the async branch was not available; you got a normal synchronous draft-send response with the verdict inline. Handle it and you are done.
-
-
After a
202, pollGET /v1/messages/:id(usingmessage_id) until the row reaches a terminal state — one ofavailable,delivered,bounced,pending_review, ordraft— readingscanand the governed send effect on the row. Branch as you would on a synchronous send:available— accepted for delivery; delivery events later advance it todeliveredorbounced.delivered/bounced— terminal, and may arrive beforeavailable(a loop that waits only foravailablecan hang).pending_review— held by the mailbox review policy; awaits approve/deny.draft— rolled back: a scanner block/quarantine or a dispatch failure refunded the budget and returned the row todraft. The verdict is on the row'sscan; the matching webhook fires. On this path a content hold surfaces as thisdraftrollback, not as aquarantined/blockedstate.
Or subscribe to the lifecycle webhooks instead of polling — the same terminal transitions emit their matching events (see /agents/webhooks).
-
Constraint: attachment-bearing drafts are sync-only — sending with
attachment_idsunderPrefer: respond-asyncreturns400 ATTACHMENTS_REQUIRE_SYNC_SEND.
The state machine and terminal-state semantics live at /agents/messages.
Errors (important)
-
403 RECIPIENT_SUPPRESSED— the recipient is on the do-not-contact list (details.reason: 'suppressed'). -
403 ACCOUNT_SUSPENDED/MAILBOX_SUSPENDED/DOMAIN_SUSPENDED/SANDBOX_TRIAL_EXPIRED/STORAGE_QUOTA_EXCEEDED— a send-time gate rejected the request. -
404 NOT_FOUND— draft not found, or its mailbox was deleted between create and send. -
409 DRAFT_REJECTED_BY_RESCAN— the send-time scan returnedblockorquarantine.details.worst_decisioncarries the verdict,details.scanthe vendor-neutral scan summary, anddetails.releasableistruefor a releasablequarantine(send it anyway via release-and-send, below) andfalsefor a terminalblock.details.hold_contextis the policy/human-review reason (ornull). The draft stays indraft; the cached scan summary is refreshed. Edit and retry. Example:{ "error": "Draft rejected by send-time scan", "code": "DRAFT_REJECTED_BY_RESCAN", "details": { "worst_decision": "quarantine", "releasable": true, "scan": { "verdict": "clean", "categories": [], "findings": [] }, "hold_context": { "trigger_source": "mailbox_policy", "summary_reasons": ["Held for review by your mailbox policy; the review queue is unavailable on your current plan."] } } } -
409 DRAFT_ALREADY_SENT— the draft already leftdraftstate. -
429 RATE_LIMITED— daily send budget exhausted; carriesdetails: { daily_limit, sends_remaining: 0, reset_at }. Budget limits are documented at /docs/limits. -
429 REPLY_LOOP_DETECTED— the reply-loop guard fired. -
503 DOMAIN_UNHEALTHY— the sending domain's health check is failing.
Full catalog and remediation: /agents/errors.
Release and send a held draft — POST /v1/drafts/:id/release-and-send
Classification: send-triggering. This is the human "send anyway" override for
a draft that was held by a send-time scan (a prior
409 DRAFT_REJECTED_BY_RESCAN with releasable: true).
Auth: admin key or session cookie only — agent keys receive
403 INSUFFICIENT_SCOPE. Loosening a scanner hold is a human decision by design.
The route skips the content rescan (it honors the scanner verdict cached at hold time) but still runs every non-content dispatch gate — suppression, allowlist, budget, domain health, account/mailbox status, and enforced MX validation.
Request
{
"release_source": "compose_inline",
"override_reason": "Manually reviewed; content is legitimate."
}release_source(required) —"compose_inline"or"quarantine_page", for audit attribution.override_reason(optional, ≤500 chars) — recorded on the audit row.
Response 200
{ "status": "sent", "message_id": "uuid" }Errors (important)
403 INSUFFICIENT_SCOPE— an agent key was used.403 BLOCK_TERMINAL— the held decision was a terminalblock; the draft must be edited before it can be sent.404 NOT_FOUND— draft not found.409 DRAFT_WRONG_STATE— the draft is not instate='draft'.409 DRAFT_NOT_HELD/DRAFT_EDITED_SINCE_HOLD— the draft was not held (or a subsequent edit cleared the hold marker); re-send viaPOST /v1/drafts/:id/sendto get a fresh verdict first.409 DRAFT_HELD_DECISION_MISSING— a legacy row with a hold marker but no recorded decision; re-send to refresh.409 ALREADY_RELEASED— a concurrent release already moved the draft out ofdraft.422 RECIPIENT_UNDELIVERABLE/RECIPIENT_ADDRESS_INVALID/RECIPIENT_DOMAIN_TYPO_SUSPECTED/RECIPIENT_ROLE_ADDRESS/RECIPIENT_DISPOSABLE_ADDRESS— enforced recipient verification confirmed a recipient-quality violation (this route shares the same send-time gate asPOST /v1/drafts/:id/send).429 RATE_LIMITED— two shapes: (a) daily budget exhausted (details: { daily_limit, sends_remaining: 0, reset_at }); or (b) a new-sender warm-up hold on the shared sending pool (details: { reason: 'new_account_warmup', retry_after_seconds }). Under (b) the draft stays held — no state change — so retry afterretry_after_secondsor verify your own sending domain to lift the warm-up.503 DOMAIN_UNHEALTHY— the sending domain's health check is failing.
Full catalog: /agents/errors.
Delete a draft — DELETE /v1/drafts/:id
Classification: mutating.
Auth: admin key, session, or agent key bound to the draft's mailbox.
Soft-deletes the draft (state='deleted') and returns 204. Subsequent GET /
PATCH / send calls on it return 404.
If the draft had a schedule set, deletion additionally emits a
message.schedule_cancelled webhook (reason: draft_deleted). Deleting an
unscheduled draft emits nothing beyond the internal delete record. If a scheduled
dispatch wins the race and sends before the delete lands, the delete is still
idempotent (returns 204) but no cancellation event fires — subscribers already
saw the terminal dispatch event.