Developers · September 21, 2026
Accepted vs Delivered: What an Email Status Proves
Queued, accepted, delivered, bounced, unknown — what each email status actually proves, what it does not, and the correct next action for every one of them.
A dashboard that says “sent” is hiding at least four different facts behind one word, and the gap between them is where an agent quietly does the wrong thing. It resends a message that already went out. It reports success to a user whose mail bounced twenty minutes later. It marks a ticket resolved because a provider returned HTTP 200.
This is the reference for what each status in the agent email API actually establishes. Every state name, error code and event name below is the one the deployed system writes. If you are building the send-and-receive integration itself, start with the two-way API loop; this page is the part of it you come back to when something looks wrong.
Five states and who reports each one
An outbound message has exactly six legal states in the database, and only one of them is transient enough that you will rarely see it. Each is written by a different actor, and knowing which actor wrote a state tells you how much it is worth.
| State | Written by | Event | Evidence behind it |
|---|---|---|---|
queued | The API request handler | message.queued | Your own request passed validation and policy. Nothing external. |
submitting | The send job, for the duration of one call | none | A submission is in flight right now. |
accepted | The send job, from the provider’s reply | message.accepted | The provider returned a submission ID. |
failed | The send job | message.failed | The provider refused, definitively, or the retry window ran out. |
submission_unknown | The send job | message.submission_unknown | Nothing. The outcome could not be established. |
canceled | The send job, before submission | not emitted in this build | Authorization or policy stopped the send after it was queued. |
Two footnotes that save time later. message.canceled and message.quarantined are
declared in the event type list but are not produced by the current build — the
canceled state exists on the message, the event does not, so read the message rather
than waiting for a callback. And inbound messages use a different state set entirely:
received, quarantined, failed, purged. Nothing on the inbound side is ever
accepted.
Per-recipient delivery is tracked separately, on message_recipients, and it is the
only place the word “delivered” ever appears.
What queued proves
That your request was well-formed, authorized, within limits, and durably stored. That is a genuinely useful fact — it means the message will not evaporate if your process dies — but it is entirely self-reported. No byte has left the building.
Reaching queued means the request passed, in order: scope checks on the calling
principal, workspace then project then inbox policy, recipient allow and block rules,
the daily recipient cap, the suppression list, and a check that the sending domain’s
sending_status is ready. It also means the canonical payload was hashed and bound
to your Idempotency-Key. If a rule had fired instead, you would have a 403 policy_denied, a 422 recipient_suppressed, a 422 domain_not_ready, or a 202 with
"result": "approval_required" and no message at all.
The correct next action for queued is to wait. It is not to re-POST. A second POST
with the same key returns the same result; a second POST with a new key is a second
email.
What provider acceptance proves
One hop. The provider’s API returned a 2xx with a submission ID, so the message is now that provider’s problem rather than ours. It is the strongest thing you can know synchronously, and it is much weaker than most integrations treat it.
The mapping from the provider’s HTTP reply to your message state is mechanical, and worth reading closely because it explains every state you will ever see:
| Provider response | Classified as | Resulting state | Recorded error code |
|---|---|---|---|
| 2xx with a submission ID | accepted | accepted | — |
| 2xx with no ID in the body | unknown | submission_unknown | missing_provider_id |
| 4xx other than 408, 409, 429 | definitely rejected | failed | the provider’s own error name, or rejected_by_provider |
| 429 | retryable before submission | stays queued | throttled, or provider_daily_quota_exceeded |
| 409 | unknown | submission_unknown | idempotency_conflict or concurrent_idempotent_request |
| 408 or any 5xx | unknown | submission_unknown | provider_timeout or provider_5xx |
| 3xx redirect | unknown | submission_unknown | unexpected_provider_redirect |
| Connection error or 15 s timeout | unknown | submission_unknown | network_error |
Note what is missing from the “rejected” row: 409 and 5xx are not failures. A 409 may mean an earlier request with the same provider idempotency key was already accepted, and a 5xx may arrive after the provider has already committed the message. Both are recorded as unknown on purpose.
Acceptance is also the moment billing commits. One outbound_recipient unit is debited
per unique recipient address when the state becomes accepted — not when it is
delivered, and not when it is queued. Billing follows acceptance because acceptance is
the last point anyone on this side can observe.
What a delivery event proves
That the recipient’s mail server said yes to the recipient’s mail server’s own satisfaction. It is the strongest signal available, it arrives asynchronously as a provider callback, and it still stops well short of “a person saw this”.
Delivery information arrives as message.delivery_updated, whose payload carries
message_id, recipient and status. The status is per address, and it is one of
delivered, bounced, complained, suppressed, delayed or failed. Recipients
start at pending.
Two properties of this pipeline matter when you are debugging:
Statuses only move forward. Each status has a rank — pending 0, delayed 1,
delivered 2, failed 3, bounced and suppressed 4, complained 5 — and an update
is applied only when its rank is greater than or equal to the current one. A late
delayed callback cannot demote an address that already reached delivered.
Out-of-order callbacks are dropped by timestamp. An update is applied only if the
provider’s created_at is not older than the status already observed. Provider
retries reorder freely, so this check is what keeps a redelivered older event from
rewriting a newer truth. This is a different mechanism from your own webhook
deduplication, which you still need —
handling duplicate events is your side of the same
problem.
There is one more thing a confirming callback does. An email.sent, email.delivered,
email.bounced, email.complained or email.suppressed callback is treated as proof
that the submission was accepted after all. If the intent was sitting in submitting
or submission_unknown, that callback promotes it to accepted, clears the error
code and commits the quota. Later evidence resolves an unknown; a retry never does.
“Submission unknown” and why you must never auto-resend it
Most email APIs collapse this case into “failed”, which is a lie with a body count. If a submission was interrupted after the request left and before an acknowledgement came back, nobody on this side knows whether an email exists. Calling that “failed” invites exactly one action — resend — and that action is wrong half the time.
The system therefore records submission_unknown and stops. Three things happen at the
same moment, and all three exist to prevent a second copy:
- The send intent’s
last_error_codeis set (interrupted_submissionwhen a worker died mid-submission, or the provider-specific code from the table above), and the job is not rescheduled. - The reserved live sending quota moves to
held_unknownrather than being released. An unknown send is not refunded, because it may have gone out. - Any daily recipient-cap reservation also moves to
held_unknown, for the same reason.
There is a 23-hour deadline on the provider idempotency key, after which a still-queued
send is marked failed with provider_retry_window_expired. That deadline does not
apply to unknown submissions. The code is explicit about it: only queued,
proven-unaccepted attempts are converted, and an unknown submission stays held rather
than becoming an unbilled failure. It resolves one of two ways — a later provider
callback confirms acceptance, or a human looks and decides.
For the human looking, the useful handle is the correlation header. Every submission
carries X-EFA-Correlation: efa_intent:<send intent id>, so the message can be matched
in the provider’s own logs without guessing from subject lines. If you find it there,
mark the send accepted and move on. If it is genuinely absent, compose a new send with
a new idempotency key deliberately, as a human decision. The console labels this state
“Send result unknown — check activity before sending again” rather than folding it into
an error count; your own interface should do the same.
Idempotency keys cover the mechanics of what a
safe retry looks like when you do decide to make one.
Per-recipient outcomes in a multi-recipient send
A message with five recipients is not one outcome; it is five, and they routinely
disagree. The message resource reflects this — each entry in recipients[] carries
email, type and its own delivery_status:
{
"id": "msg_2f1c...",
"state": "accepted",
"recipients": [
{ "email": "ops@example.com", "type": "to", "delivery_status": "delivered" },
{ "email": "billing@example.com", "type": "cc", "delivery_status": "bounced" },
{ "email": "archive@example.net", "type": "bcc", "delivery_status": "pending" }
]
}
The message state stays accepted. It does not become failed because one address
bounced. The array is the authoritative record, and any summary you show a user has to
be computed from it: the console derives “Delivered to all recipients”, “Accepted ·
delivery varies by recipient” and “Accepted · awaiting delivery update” from exactly
this data rather than from a roll-up field. Collapsing those three into a green tick is
how someone concludes an invoice was received when it was not.
One safeguard worth knowing about: when a provider callback names more than one recipient, it is not applied to any of them. An aggregate payload cannot establish which address bounced, and guessing would suppress addresses that are perfectly fine. Sends are capped at 25 unique recipients, so the blast radius of that ambiguity is bounded, but the rule is what keeps it at zero.
Hard bounces, soft bounces and suppression
The distinction that matters operationally is not hard versus soft. It is whether the outcome added the address to a suppression list, because that changes what happens to your next send, not this one.
| Provider callback | Recipient status | Suppresses the address? | Reason recorded |
|---|---|---|---|
email.delivery_delayed | delayed | No — a retry is still in progress | — |
email.bounced, transient | bounced | No | — |
email.bounced, permanent | bounced | Yes | hard_bounce |
email.complained | complained | Yes | complaint |
email.suppressed | suppressed | Yes | provider |
email.failed | failed | No | — |
delayed is the soft-bounce signal. It ranks below delivered, so an address that is
delayed and then delivered ends up delivered, with the intermediate state visible in
the event stream. Treat a delayed status as information, not as an instruction to
send anything.
Suppression is recorded at workspace scope against a SHA-256 digest of the canonical
address, so the address itself is not stored a second time. Once it is active, the
next send to that recipient is refused at request time with 422 recipient_suppressed, and that error is explicitly marked non-retryable. This is the
one case where your integration should surface the failure to a human immediately: a
suppressed address is a standing fact about the relationship, not a transient error.
A suppressed callback also reverses billing. The debited outbound_recipient unit for
that address is written back as a reversal entry against the original debit, and the
period’s usage count is decremented. You are not billed for a recipient the provider
decided not to attempt.
What no status proves: inbox placement and human attention
delivered means a receiving mail server accepted the message. What that server did
next is invisible from here. It may have filed the message in a spam folder, applied a
rule that archived it, or shown it in a promotions tab the recipient never opens. No
email provider can report inbox placement, because the receiving system does not tell
the sender where it put things.
Nor does any status prove attention. There is no open tracking or read receipt in this API, deliberately: pixel tracking is unreliable in the presence of image proxies and privacy filters, and building an agent’s control flow on a signal that is both absent and wrong in different directions is worse than having no signal.
The honest ceiling, stated as a sequence: we know we stored it, we know a provider took
it, we know a receiving server accepted it. We do not know it was seen. If your
workflow depends on a human having acted, the evidence is a reply arriving as
message.received — not a delivery status.
The same caution applies upstream. Verified SPF, DKIM and a ready sending domain are
configuration evidence, not a statement about where mail lands. There is no delivery
guarantee and no published uptime SLA here, and anyone offering you one for email is
describing something they do not control.
A diagnosis order that works
When someone reports that an agent’s email “did not arrive”, work down this list. Each step rules out a whole class of cause, and the order matters — most reports resolve in the first three.
- Find the message and read its
state. List the inbox’s messages, or the thread’s, and look at the state field. If there is no message at all, the send never happened: check whether the call returned"result": "approval_required", in which case a draft is waiting for a human. - If the state is
queued, nothing has been submitted yet. Check whether sending is paused on the workspace, project or inbox policy — a403 policy_deniedwithsend_disabledon the original request is the giveaway — and check the daily recipient cap. - If the state is
failed, readmessage.failed’sreasonfield. The two the send job writes areprovider_retry_window_expiredandreply_parent_headers_not_available; anything else came from the provider’s own rejection. - If the state is
submission_unknown, stop and go to the previous section. Do not send anything while you investigate. - If the state is
accepted, the question is now per recipient. Readrecipients[]and find the specific address that was reported. - If that recipient is
pending, no callback has arrived yet. Replay the event stream from your stored cursor before concluding anything — a missed webhook looks identical to a missing delivery. - If that recipient is
bounced,suppressedorcomplained, the message will not arrive and re-sending will be refused. Fix the address or the relationship. - If that recipient is
delivered, the message reached a mail server that accepted it. From here the investigation is about placement and filtering on the recipient’s side, and it is no longer a question your API can answer.
Two habits make this loop much shorter. Log the x-request-id from every API response,
because it is the identifier that correlates a request with its record. And drive the
whole thing from the durable event cursor rather than from webhook arrivals alone —
webhooks are for latency, the cursor is for truth.
You can exercise steps 1 through 5 end to end without sending anything, in a test project, and you should before the first live message goes out. What a sandbox cannot produce is a real bounce or a real suppression, because those come from receiving systems that are not there — testing agent email without sending real mail sets out exactly where that boundary sits. For the wider picture of how these states fit into an agent’s mailbox, how agent inboxes actually work is the overview.
Keep reading
Agent Email API: Send, Receive and Reply in One Loop
Build the full two-way loop over REST: scoped keys, idempotent sends, the states a message moves through, inbound events, and replies that thread correctly.
Idempotency Keys: Stop an Agent Sending Duplicate Emails
How send idempotency works: canonical payload hashing, the 30-day replay window, conflict versus expiry, and why retrying an unknown outcome is unsafe.
Handle Email Webhooks Without Processing Events Twice
Verify the signature against the raw body, deduplicate by event ID, enqueue durably, and keep a webhook retry from re-sending the email that produced it.