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:

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 outcomeResulting stateEvent emitted
accepted, with a provider IDacceptedmessage.accepted
definitely rejectedfailedmessage.failed
retryable before submission (429)stays queuednone, the job is rescheduled
anything elsesubmission_unknownmessage.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):

  1. Sets the intent to submission_unknown with the internal code interrupted_submission.
  2. Sets the message to submission_unknown.
  3. Holds the live send quota and the daily-limit reservation.
  4. 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.

StateEvent in this buildCan it change later?Email unitsYour next action
queuedmessage.queuedYes, dispatch moves it onReservedWait
acceptedmessage.acceptedPer-recipient delivery keeps changingCommitted, one per recipientTrack recipients[]
failedmessage.failedNoReleasedNothing went out; decide
submission_unknownsometimes, see aboveNot through anything in the APIHeld, neither used nor freedStop; read state; escalate
cancelednoneNoReleasedNothing was sent
submittingnoneInternal only, never on the message rowReservedNot 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:

  1. Stop. Do not retry with a new key and do not retry the task that produced the send.
  2. Record message_id, thread_id and every x-request-id you logged for the send.
  3. 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).
  4. 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 the message_id and ask for the send to be checked; until you hear back, treat the message as possibly sent.
  5. 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

All guides · Documentation · Developers