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.

StateWritten byEventEvidence behind it
queuedThe API request handlermessage.queuedYour own request passed validation and policy. Nothing external.
submittingThe send job, for the duration of one callnoneA submission is in flight right now.
acceptedThe send job, from the provider’s replymessage.acceptedThe provider returned a submission ID.
failedThe send jobmessage.failedThe provider refused, definitively, or the retry window ran out.
submission_unknownThe send jobmessage.submission_unknownNothing. The outcome could not be established.
canceledThe send job, before submissionnot emitted in this buildAuthorization 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 responseClassified asResulting stateRecorded error code
2xx with a submission IDacceptedaccepted
2xx with no ID in the bodyunknownsubmission_unknownmissing_provider_id
4xx other than 408, 409, 429definitely rejectedfailedthe provider’s own error name, or rejected_by_provider
429retryable before submissionstays queuedthrottled, or provider_daily_quota_exceeded
409unknownsubmission_unknownidempotency_conflict or concurrent_idempotent_request
408 or any 5xxunknownsubmission_unknownprovider_timeout or provider_5xx
3xx redirectunknownsubmission_unknownunexpected_provider_redirect
Connection error or 15 s timeoutunknownsubmission_unknownnetwork_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:

  1. The send intent’s last_error_code is set (interrupted_submission when a worker died mid-submission, or the provider-specific code from the table above), and the job is not rescheduled.
  2. The reserved live sending quota moves to held_unknown rather than being released. An unknown send is not refunded, because it may have gone out.
  3. 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 callbackRecipient statusSuppresses the address?Reason recorded
email.delivery_delayeddelayedNo — a retry is still in progress
email.bounced, transientbouncedNo
email.bounced, permanentbouncedYeshard_bounce
email.complainedcomplainedYescomplaint
email.suppressedsuppressedYesprovider
email.failedfailedNo

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.

  1. 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.
  2. If the state is queued, nothing has been submitted yet. Check whether sending is paused on the workspace, project or inbox policy — a 403 policy_denied with send_disabled on the original request is the giveaway — and check the daily recipient cap.
  3. If the state is failed, read message.failed’s reason field. The two the send job writes are provider_retry_window_expired and reply_parent_headers_not_available; anything else came from the provider’s own rejection.
  4. If the state is submission_unknown, stop and go to the previous section. Do not send anything while you investigate.
  5. If the state is accepted, the question is now per recipient. Read recipients[] and find the specific address that was reported.
  6. 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.
  7. If that recipient is bounced, suppressed or complained, the message will not arrive and re-sending will be refused. Fix the address or the relationship.
  8. 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

All guides · Documentation · Developers