Developers · September 29, 2026
What message.submission_unknown Actually Means
The send was attempted and nobody knows if it went out. What message.submission_unknown means, the silent path to it, and why resending is the wrong move.
Your webhook handler receives an event it has no branch for: message.submission_unknown.
It is not message.failed, so nothing was rejected. It is not message.accepted, so
nothing was confirmed. The agent that sent the message wants a yes or a no, and the
honest answer from the system is neither. What does message.submission_unknown mean, and
what is the agent allowed to do next?
This page is the precise reference for that one state: the two ways a send reaches it,
the fact that one of those ways produces no event at all, what it does to your email units,
and why a retry cannot resolve it. Every behaviour below is read from source, mostly the
dispatch code in packages/domain/src/services.ts, with line numbers. For the wider map
of what each status proves, read accepted vs delivered
first.
What does message.submission_unknown mean?
It means a submission to the sending provider was attempted and its outcome could not be established. The message may be on its way to the recipient. It may never have left. Nothing recorded on this side can tell those two cases apart, so the system records the uncertainty as its own state instead of guessing.
An API with no such state has to fold this case into an error, and an agent’s retry policy reads “error” as “try again”. That is the behaviour this state exists to prevent. The distinction is exact:
failedmeans the provider definitely refused the message. Nothing went out.acceptedmeans the provider definitely took the message. One hop succeeded.submission_unknownmeans the attempt happened and the answer is missing.
The event, when it is emitted, carries one field: data.message_id
(services.ts:4459-4461). There is no reason code on it, and the message resource does
not expose one either. The internal error code the system records against the send is not
part of the listing response (services.ts:3110-3125). You learn that the outcome is
unknown; you do not learn why.
The two paths into the state, and the one that emits nothing
A send is dispatched by a background job. The job marks the send intent submitting,
commits that, and only then calls the provider (services.ts:4398-4400, then :4420).
When the provider call returns, its outcome is classified four ways on one line
(services.ts:4430):
| Provider outcome | Resulting state | Event emitted |
|---|---|---|
| accepted, with a provider ID | accepted | message.accepted |
| definitely rejected | failed | message.failed |
| retryable before submission (429) | stays queued | none, the job is rescheduled |
| anything else | submission_unknown | message.submission_unknown |
“Anything else” covers a 5xx, a 408, a 409, a redirect, a network error, the 15-second
client timeout (packages/mail/src/resend.ts:134), and a 2xx with no ID in the body. It
also covers an exception thrown by the provider client itself, which is caught and
recorded as provider_exception_after_submission (services.ts:4425). The per-response
mapping is laid out in the accepted vs delivered
reference; the point here is that this path ends in message.${state}, so
message.submission_unknown is written in the same transaction as the state change
(services.ts:4459-4461). If you see the event, the state is already committed.
The second path is quieter. Send jobs hold a 60-second lease (services.ts:4168). If the
worker dies, or loses its lease, while the intent is submitting, the next worker to claim
the job finds that state and does this (services.ts:4356-4363):
- Sets the intent to
submission_unknownwith the internal codeinterrupted_submission. - Sets the message to
submission_unknown. - Holds the live send quota and the daily-limit reservation.
- Returns. No event is written.
The comment above that block states the intent directly: “Conservatively hold for
reconciliation; never convert this into a new submission.” A worker that loses its lease
after the provider has replied is covered by the same rule. Its result write checks the
lease first and discards the provider’s answer if the lease has expired
(services.ts:4429), and the fence increment on reclaim means a late write cannot land
anyway (services.ts:4359, :4433). Even a reply that said “accepted” is lost that way.
Two consequences follow.
A message can be in submission_unknown with no message.submission_unknown event
anywhere in your stream. An integration that only reacts to webhooks will never learn
about it.
An interrupted submission may not have reached the provider at all. The submitting
state is committed before the provider call, so a worker that died in between leaves the
same evidence as one that died after the provider accepted. The system cannot tell those
apart, which is exactly why it refuses to resubmit.
One more detail makes the silent path harder to spot. The message resource never shows
submitting. The database allows that value on outbound messages, but the dispatch code
only ever sets it on the send intent, never on the message row. From the API, an
interrupted send goes straight from queued to submission_unknown.
How it differs from every neighbouring state
The six outbound states, side by side. “Event” means what this build actually emits, not what the event list declares.
| State | Event in this build | Can it change later? | Email units | Your next action |
|---|---|---|---|---|
queued | message.queued | Yes, dispatch moves it on | Reserved | Wait |
accepted | message.accepted | Per-recipient delivery keeps changing | Committed, one per recipient | Track recipients[] |
failed | message.failed | No | Released | Nothing went out; decide |
submission_unknown | sometimes, see above | Not through anything in the API | Held, neither used nor freed | Stop; read state; escalate |
canceled | none | No | Released | Nothing was sent |
submitting | none | Internal only, never on the message row | Reserved | Not visible to you |
message.quarantined is declared in the event list but is never produced, and it could
not describe an outbound send anyway: the database only permits quarantined on inbound
messages (supabase/migrations/20260919120200_mail_resources.sql:146-152).
message.canceled is also declared and not emitted. The canceled state is set when
the dispatch-time re-check fails (authorization, policy, the daily cap, an approval that is
no longer valid, or send content that went missing), and the code writes no event for it
(services.ts:4391-4396). The events and webhooks
docs mark both as not produced.
What submission_unknown never becomes is failed. Sends that
wait too long in queued are converted to failed with provider_retry_window_expired
once their 23-hour provider idempotency deadline passes (services.ts:4365-4374). The
comment on that block is explicit that unknown submissions never take that path: “Only
queued, proven-unaccepted attempts reach here.” There is no timer that ages an unknown
send into a failure, because a failure releases the quota and invites a resend.
Why resending with the same Idempotency-Key cannot answer the question
It is tempting to treat a retry with the original key as a status check. It is safe, and it tells you nothing about the outcome.
When the API first takes a send, it stores the response alongside the key as a
snapshot: result: "queued", message_id, thread_id, state: "queued"
(services.ts:1973-1985). A later request with the same key and the same canonical payload
finds the record and returns that snapshot as HTTP 202, before the suppression check and
the quota reservation run (services.ts:1723-1757). Nothing ever updates the snapshot.
Only the request_id is fresh, because the route overwrites it with the current
request’s (packages/api/src/index.ts:250). So a retry of a message that is now
submission_unknown replies:
{
"result": "queued",
"message_id": "msg_...",
"thread_id": "thd_...",
"state": "queued",
"request_id": "req_..."
}
That state is the state at the moment the API first took the send, and it stays that
way for the whole 30-day replay window. What the retry does give you is safety and the
identifiers: no second email, no second quota reservation, and the message_id and
thread_id you need to look the message up.
One ordering detail matters. Scope checks and policy evaluation run before the key is
looked up (services.ts:1509-1516, :1543). If a policy has since turned sending off for
the inbox, or now blocks one of the recipients, the retry gets 403 policy_denied instead
of the snapshot. That is still not a second email, and it still says nothing about the
first one. If a policy now requires approval, the approval path looks up the same key and
returns the same stored snapshot (services.ts:1609-1618), so no new draft is created. The other outcomes of reusing a key, the conflict
on a changed payload and the expiry after 30 days, are in the
idempotency key glossary entry.
A retry with a new key is a different thing entirely. It is a second intended
message, with its own send intent, its own reservation and its own provider idempotency
key. The provider key is the send intent’s own ID (services.ts:1926), passed on every
attempt of that intent (services.ts:4420), so the provider’s own deduplication cannot
connect a new intent to the old one. If the first message did go out, the recipient gets
two.
Reading the state without trusting the event stream
Because one path to this state emits nothing, the message resource is the authority and
the event stream is a hint. There is no single-message GET in the API. The narrowest
lookup is the thread listing, using the thread_id from the send response. It returns up
to 50 messages in thread order by default (packages/domain/src/mail-pages.ts:148,
:159), and each carries state (services.ts:3476):
curl "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/inboxes/$EFA_INBOX_ID/threads/$THREAD_ID/messages" \
-H "Authorization: Bearer $EFA_API_KEY"
The inbox listing, GET /v1/projects/{project_id}/inboxes/{inbox_id}/messages, carries the
same field, newest first, 25 per page by default. The body endpoint does not include
state, and neither does the MCP get_message tool, which reads the body; over MCP,
list_messages is the call that shows it.
A sweep that closes the gap is short. Keep a record, on your side, of every send you have not yet seen reach a settled state, and re-read those periodically:
const API = "https://api.emailforagents.ai/v1";
const SETTLED = new Set(["accepted", "failed", "canceled", "submission_unknown"]);
export async function readSendState({ projectId, inboxId, threadId, messageId, apiKey }) {
const url = `${API}/projects/${projectId}/inboxes/${inboxId}/threads/${threadId}/messages`;
let cursor;
do {
const next = cursor ? `${url}?cursor=${encodeURIComponent(cursor)}` : url;
const res = await fetch(next, { headers: { Authorization: `Bearer ${apiKey}` } });
if (!res.ok) throw new Error(`thread listing returned ${res.status}`);
const page = await res.json();
const hit = page.data.find((m) => m.id === messageId);
if (hit) return { state: hit.state, settled: SETTLED.has(hit.state) };
cursor = page.has_more ? page.next_cursor : undefined;
} while (cursor);
return { state: null, settled: false };
}
Run it against anything your side still believes is queued. Do not put a deadline on
queued and call the send failed when it passes: a throttled send can legitimately sit
in queued for a long time inside its 23-hour window. The sweep is there to discover a
transition you were never told about, not to invent one. Poll the event cursor at
GET /v1/projects/{project_id}/events as well, because it is the durable record of the
events that do exist; the events and webhooks docs cover
the cursor.
What happens to your email units while it is unknown
An email unit is one recipient sent to. A live send reserves one unit per unique recipient
at the moment the API takes it. On submission_unknown, that reservation moves to
held_unknown and stops there (packages/domain/src/billing.ts:870-873, reached through
holdLiveSendQuota at services.ts:4531). It is not committed to usage, so no
outbound_recipient debit is written. It is not released either.
The practical effect comes from how the next send is admitted. The reservation check
counts used + reserved against the allowance (billing.ts:769-770), and a held unit is
still in reserved. So a held send to four recipients removes four units from what the
workspace can spend in that billing period, without appearing as usage. On a free
allowance of 100 units a month, a handful of unknown multi-recipient sends is a visible
dent.
If a policy sets a daily recipient cap, that behaves the same way. Reservations move to
held_unknown (packages/domain/src/daily-limits.ts:33) inside a bucket keyed by UTC
date (daily-limits.ts:8), so they keep counting against that day’s cap until the day
ends.
Test sends never touch either: live quota is reserved only for live inboxes
(services.ts:1931-1936), and daily limits return early for anything that is not live
(daily-limits.ts:6-7).
Does anything resolve it later?
Not through anything you can observe or trigger in this build. There is a reconciliation
branch: when a provider callback that implies acceptance arrives (the provider-side
callback types email.sent, email.delivered, email.bounced, email.complained and
email.suppressed), an intent in submitting or submission_unknown is promoted to
accepted and its quota is committed (services.ts:4559-4564). Those names are the
provider’s callback types, not customer events. You cannot subscribe to them, and they
are never delivered to your webhook.
Two facts limit that branch. First, the promotion emits no message.accepted. If the
callback was a delivery outcome for a single recipient, you get a
message.delivery_updated; if it was email.sent, which has no delivery status mapped
(services.ts:4540), you get nothing at all. Second, the callback is matched to a send by
the provider’s email ID (services.ts:4546), and that ID is written only when the
provider’s reply was classified as accepted (services.ts:4431-4432). A send that became
unknown never recorded one, so a later callback has nothing to match, and it falls through
to the unmatched-callback path (services.ts:4548-4556). The same is true of the
sent-headers reconciliation job, which only picks up sends already in accepted with a
provider ID (packages/domain/src/sent-message-headers.ts:19-20).
Treat submission_unknown as final from the API’s point of view. Design your side on
the assumption that nothing will move it. If a later read ever shows accepted, take it,
but never wait for it. The agent’s next steps are:
- Stop. Do not retry with a new key and do not retry the task that produced the send.
- Record
message_id,thread_idand everyx-request-idyou logged for the send. - Surface it to a person as “result unknown”, never as “failed”. The console uses the
label “Send result unknown” and tells the operator to check activity before sending
again (
apps/console/src/mail.ts:91-92). - Look for evidence that it arrived: a reply in the thread, or confirmation from the
recipient. Every submission carries
X-EFA-Correlation: efa_intent:<send intent id>(services.ts:4424,packages/mail/src/resend.ts:112), which matches it in the sending provider’s logs. That provider account is configured on the platform (services.ts:2245-2246), not in your workspace, so you cannot search it yourself. Email support@agents.emailforagents.ai with themessage_idand ask for the send to be checked; until you hear back, treat the message as possibly sent. - If a person concludes it did not go out, they send it again, once, under a new key, as a deliberate decision. The original stays held.
You cannot rehearse this in a test project on demand. The simulated transport accepts by
default, and although a submission_unknown scenario is declared in the contracts
(packages/contracts/src/schemas.ts:93-97), no route accepts it. Test the handler with a
recorded event payload and a stubbed listing instead. The complete send-side retry rules
are in retrying agent email safely, and the rest of
the send and receive surface is on the developer overview.
Keep reading
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.
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.
My Agent Says It Sent It. Nobody Received It
Your AI agent says it sent an email and nobody received it. Work the states in order, learn which ones emit no event at all, and never retry one.