Developers · September 23, 2026
Agent Email Polling vs Webhooks: A Decision Table
Agent email polling vs webhooks, decided on failure modes: cursor semantics, event-id dedup, and the exact 4xx codes that kill a delivery on the first try.
An agent that sends mail is half a system. The other half is finding out that something happened: a reply landed, a recipient bounced, a human rejected a draft. There are two ways to learn that, and most write-ups on agent email polling vs webhooks settle it on latency and wasted requests, as though the only questions were how many seconds you can tolerate and how many empty polls you are paying for. Those are the least interesting axes. Both transports are at-least-once, both will hand you the same event twice, and they lose events in ways that have nothing in common.
This article decides it on failure modes instead, with the classification rules read out of the dispatcher so you can predict what happens before it happens. If you want the whole request and response loop rather than just the inbound half, read the agent email API loop first.
Agent email polling vs webhooks, as a decision table
Read the row that matches your constraint, not the column that matches your taste.
| Your situation | Use | Why |
|---|---|---|
| Human waiting on the reply | Webhook + cursor | Poll interval is a floor on response time you cannot lower cheaply. |
| You are provisioning from CI with an API key | Cursor only | Endpoint creation refuses every non-human principal. See the next section. |
| Laptop, CI, or anything without a public HTTPS origin | Cursor only | Registration rejects non-public URLs, and re-checks DNS at delivery. |
| Credential scoped to a subset of inboxes | Cursor only | The cursor inherits the key’s inbox list. An endpoint gets the project. |
| You need event ordering | Cursor | Only the polled representation carries a sequence number. |
| Batch job that runs hourly anyway | Cursor only | A webhook adds an endpoint to operate and gains you nothing. |
| Your handler is behind a WAF or a framework with a 4xx | Cursor as backstop | A stray 4xx is terminal. The event is gone from the push path. |
| You rotate the webhook secret, or disable the endpoint | Cursor to catch up | Queued deliveries are cancelled and never replay. |
| High volume, and you care about wasted requests | Webhook + cursor | Poll lazily when the push path is the primary signal. |
Two rows carry the whole argument. The cursor is the only complete record; the webhook is an optimisation over it. Every way a webhook loses an event, the cursor still has it. The reverse holds only in the trivial sense that the cursor cannot tell you about an event faster than you ask for one.
An API key cannot create a webhook endpoint at all
This is the constraint that decides the most cases and the one nobody expects, so it
goes before the mechanics. Creating an endpoint runs through the shared controls
authoriser with write set, and that authoriser starts with a requireHuman check
(packages/domain/src/authorize.ts:34, called from createWebhook in
packages/domain/src/services.ts). The principal type on the request must be human.
The four principal types are human, api_key, connection and internal_job
(packages/contracts/src/principal.ts:105), so an API key is refused, and so is an
OAuth connection grant driving the MCP tools.
It goes further than the type check. Because the write flag is set, the authoriser
re-reads the human’s current grant and requires the role to be owner or admin. A
developer holding webhooks:manage still gets permission_denied. The same gate
covers disable, enable and secret rotation, and those requests also carry an
expected_revision that must match or the call fails version_conflict
(packages/contracts/src/controls.ts:23).
The practical consequence: you cannot stand a webhook up from a script. A key held
by an agent runtime or a CI job can read deliveries with webhooks:read and do nothing
else to the endpoint. A person with the right role creates it. Polling needs one scope,
events:read, and any principal type can hold it, so if your deployment story is “the
agent provisions its own plumbing”, the cursor is not the fallback, it is the only
option. The wider rules are in
how agent email permission scopes actually resolve.
What the events cursor returns, field by field
curl "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/events?cursor=902&limit=100" \
-H "Authorization: Bearer $EFA_API_KEY"
{
"data": [
{
"id": "evt_...",
"type": "message.delivery_updated",
"sequence": 907,
"occurred_at": "2026-09-23T14:40:11.903Z",
"data": { "message_id": "msg_...", "inbox_id": "ibx_..." },
"environment": "live",
"api_version": "2026-09-19"
}
],
"next_cursor": "907",
"has_more": true
}
The credential needs events:read. The cursor is the project sequence number rendered
as a decimal string, and the query is a strict > on it, so passing back the
sequence of the last row you committed is equivalent to passing next_cursor.
limit is 1 to 100 and defaults to 25; anything else is invalid_request, not a
silent clamp. has_more is computed by selecting one row past your limit and
discarding it, so it is exact rather than a guess.
Four behaviours are worth knowing before you write the loop, all read out of the
listEvents implementation in packages/domain/src/services.ts.
An empty page returns next_cursor: null, not your cursor. The field is derived
from the last row of the page, and an empty page has no last row (services.ts:3182).
An agent that does cursor = response.next_cursor on every tick will, the first time
it catches up to the head of the stream, set its cursor to null and reprocess the
entire retained history on the next poll. Advance only when data is non-empty.
A malformed cursor and an expired cursor are different errors. Anything that is
not 1 to 19 digits within the signed 64-bit range fails immediately as HTTP 400
invalid_cursor. A well-formed cursor that has fallen behind the oldest retained
event fails as HTTP 410 cursor_expired with retryable: false. On a 410 there is
nothing to retry: drop the cursor, restart from the beginning of what remains, and let
your event-id table skip what you already handled.
The 410 cannot actually fire in this build, and you should still handle it. The
expiry test compares your cursor against MIN(project_sequence) for the project, and
it is stored + 1 < oldest, so a cursor sitting on the oldest surviving event, or one
below it, still passes. More to the point, nothing prunes the events table. There is
no retention job, and the deletion path tombstones records rather than removing them,
reporting physical_purge_not_implemented (services.ts:4249). Write the branch,
because a cursor you persisted months ago is what breaks the day retention ships, but
do not alert on it as though it were routine.
Sequence gaps are normal for a scoped key. The query filters rows by the inboxes on
the principal, while sequence is allocated per project. A key restricted to one inbox
in a busy project will see 907, 911, 934 and nothing is wrong. Do not build a gap
detector that treats a missing number as a lost event, because for any credential that
is not workspace-wide it will alert constantly.
What a webhook delivery carries, and what it leaves out
Register an endpoint, subscribe to types, and store the secret from the response,
because it is shown once. Only public HTTPS origins are accepted, and the check is
stricter than “starts with https”. The URL guard rejects any scheme but HTTPS, any
port other than 443, embedded userinfo, a fragment, and any host that is a private or
link-local IP, localhost, or ends in .local, .internal or a trailing dot
(packages/domain/src/ssrf.ts:10). The same guard runs again at dispatch, and the
egress path re-resolves DNS and refuses private, link-local and metadata addresses at
that point too. Passing registration is not a promise that delivery will be
attempted. A hostname that resolves publicly today and to 10.0.0.0/8 tomorrow
starts failing at dispatch, not at registration.
Each callback arrives with webhook-id, webhook-timestamp and webhook-signature.
The signature is v1, followed by a base64 HMAC-SHA256 over the exact string
id.timestamp.body, so verify against the raw request bytes before parsing anything.
The reference verifier rejects a timestamp more than 300 seconds away in either
direction. Header formats and a worked example are in
the events and webhooks reference.
The body is smaller than the polled representation, and the difference matters:
{
"id": "evt_...",
"type": "message.delivery_updated",
"api_version": "2026-09-19",
"data": { "message_id": "msg_...", "inbox_id": "ibx_..." }
}
There is no sequence and no occurred_at in a webhook body. The dispatcher
serialises exactly those four fields (packages/domain/src/webhook-jobs.ts:54).
webhook-timestamp is the moment the attempt was signed, not the moment the event
happened, so on a redelivery twelve hours later it is twelve hours late. If your
handler needs to know that event A preceded event B, you cannot get that from the push
path. Either look the sequence up through the events endpoint, or make the handler
order-independent. Most agent handlers should be order-independent anyway, because two
events dispatched concurrently have no ordering guarantee between them.
The other asymmetry is scope. The events endpoint narrows to the inboxes the credential
holds. Webhook endpoints are selected by workspace, project, active status and
subscribed type, and there is an allowed_inboxes column in the fan-out predicate
(webhook-jobs.ts:14) which would narrow them the same way. No code path writes it.
Creation inserts nine columns and that is not one of them, so it is NULL on every
endpoint and the filter always passes. Treat an endpoint as project-wide, because
today it is. If two agents in one project must not see each other’s mail, inbox-scoped
keys plus polling give you that boundary and a shared endpoint does not.
Subscription is also not proof of emission. The type list is validated against the
declared vocabulary, so you can subscribe successfully to approval.approved or
approval.invalidated. Both are declared in packages/contracts/src/events.ts and
neither is emitted by this build, so the subscription is accepted and silently never
fires. Of the approval events only approval.requested and approval.rejected are
produced. One more exclusion is hard-coded: webhook.test is skipped by the fan-out
entirely, because the test route creates its own single delivery. Subscribing a second
endpoint to webhook.test and firing a test at the first will never reach the second.
The retry classification, exactly as the dispatcher computes it
This is the rule people get wrong, so here is the expression from
packages/domain/src/webhook-jobs.ts:72:
const terminal =
accepted ||
prepared.attempt >= 8 ||
(status !== null && status >= 400 && status < 500 && ![408, 409, 425, 429].includes(status));
Read it as three clauses.
- A 2xx from your endpoint marks the delivery
succeededand stops. - The eighth attempt is the last one regardless of outcome. The delivery moves to
exhausted. - Any 4xx other than 408, 409, 425 or 429 is terminal on the first attempt. Not
retried later, not queued, not surfaced as a failure you can replay. The delivery is
marked
exhaustedand the event survives only in the cursor.
That third clause is the one that costs people mail. 408, 409, 425 and 429 are treated as transient because each is a statement about timing rather than about the request: a timeout, a concurrency conflict, an early-data replay, a rate limit. Everything else in the 4xx range is read as “this request will never work”. Which is correct, and is also what your framework returns when a request arrives with an unexpected content type, when a WAF rule trips, when auth middleware rejects an unauthenticated path, or when a router 404s because you shipped a prefix change. All of those are permanent loss on the push path.
The practical rule: your webhook handler must never return a 4xx for a condition that could clear. If your database is down, return 500. If you cannot verify the signature, return 401 and accept that you have discarded the event, because that one is genuinely permanent. If you are unsure which it is, return 500. A 500 is retried and a 4xx is not, so the asymmetry in cost is total.
Anything with no HTTP status classifies separately. A connection error, a DNS refusal, a
dispatcher that is itself unavailable, or an attempt that runs past the 25-second abort
in packages/domain/src/webhook-egress.ts all produce a null status, so clause three
cannot fire and only the attempt ceiling stops them. Those get all eight attempts. The
25-second figure is worth internalising: a handler that does its work inline and answers
in 30 seconds is recorded as a transport failure and retried, which is a duplicate you
manufactured yourself.
Backoff between attempts is a fixed table, not exponential with jitter: 10 seconds, then 60, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours. Add them up and the eighth and last attempt fires roughly 20 hours 36 minutes after the first. Your reconciliation poll needs to run more often than your endpoint is likely to be down, not more often than that window.
Every attempt is readable back from the deliveries listing for an endpoint: attempt
number, timestamp, outcome, HTTP status, and up to 200 characters of the response body.
Outcomes are accepted, rejected, or held_egress_unavailable when egress is not
configured. The listing returns 25 deliveries ordered by next attempt time, descending,
each with its full attempt history. It is the fastest way to tell whether an event was
killed by clause three or never dispatched at all.
Both are at-least-once. The duplicates are not the same duplicates.
Deduplicate on the event ID, in both paths, before doing any work. That one
instruction covers everything below. The id field of a polled event and the
webhook-id header of a pushed one are the same evt_ identifier for the same event,
so a single table of processed event IDs protects both paths at once, including the case
where the webhook and the reconciliation poll both deliver the same event.
Where they differ is why the duplicate happened, and that changes what you should log:
- Webhook duplicate. Your endpoint received the request, did the work, and the
response was lost or too slow, so the dispatcher counted the attempt as failed and
tried again. Same event ID, higher
attempt_number. Common and expected. - Cursor duplicate. You processed a batch and crashed before persisting
next_cursor, so the next poll re-reads from the old position. Same event IDs, contiguous, always a suffix of the last batch you handled.
The cursor variety has a fix beyond dedup: persist the cursor after the batch is committed, never when the response arrives. If persisting the cursor and processing the batch share a transaction, the window closes entirely. The push path has no equivalent fix, because the duplicate is created by a response your handler already sent.
Neither of these is the send-side duplicate. An Idempotency-Key stops one intended
message becoming two emails; an event-ID table stops one event becoming two handler
runs. They protect different things and neither substitutes for the other.
Retrying an agent send safely covers the send side,
and handling email webhooks without processing events twice
covers the handler in more depth than this piece does.
Four cases where the recommendation flips
The default is webhook plus cursor. These are the cases where it changes.
You rotated the secret or toggled the endpoint. Disabling, enabling and rotating all
bump the endpoint revision, and every delivery still pending or running for that
endpoint moves to canceled in the same transaction. The response tells you how many,
in canceled_deliveries, and says in its own note field that they will not replay.
Anything that slipped past the bulk update is caught at dispatch, where a delivery whose
stored revision no longer matches the endpoint is cancelled rather than sent. Rotation
is abrupt in one more way: the schema has previous_secret_ciphertext and
previous_secret_expiry columns, and the rotation statement sets both to NULL. There is
no dual-secret grace window. An HTTP request already in flight can land at your handler
signed with the old secret, and you will have no way to verify it. Sweep the cursor
after every rotation.
Your credential is inbox-scoped. Covered above, and it is a security property rather than a convenience: the events query applies the principal’s inbox list, a project endpoint does not. If the isolation matters, poll.
You are in test mode. Test inboxes use a non-routable .mail.invalid address and a
simulated transport, and events flow the same way, with the environment as a field on
the event rather than a separate stream. A CI run that already drives the API has no
reason to stand up a public HTTPS origin when the cursor is one more request, and as
above it could not create the endpoint with its own key anyway. The
sandbox walkthrough has the setup.
The event you care about has no handler work attached. If all you do with
message.accepted is update a status column that the next user request reads anyway, a
webhook is infrastructure you have to keep alive for no latency benefit. Note also that
acceptance is not delivery, so the event you probably want is the per-recipient one.
What each message status actually proves sets out
which is which.
Running both: webhook for latency, cursor for truth
The shape that survives contact with production is small. The webhook handler does as little as possible, and a periodic sweep closes whatever the push path dropped.
// Handler: verify, record, enqueue. No business logic on the request path.
export async function onWebhook(req) {
const raw = await req.text(); // exact bytes, before parsing
if (!verifySignature(req.headers, raw)) return new Response("bad sig", { status: 401 });
const event = JSON.parse(raw);
const fresh = await db.insertIfAbsent("processed_events", { id: event.id });
if (fresh) await queue.push(event); // durable; handler stays fast
return new Response(null, { status: 204 }); // 2xx even on a duplicate
}
// Sweep: the same dedup table, so an event delivered both ways runs once.
export async function sweep() {
let cursor = await db.getCursor(); // null on first run
for (;;) {
const res = await fetch(
`${BASE}/v1/projects/${PROJECT}/events?limit=100` + (cursor ? `&cursor=${cursor}` : ""),
{ headers: { authorization: `Bearer ${KEY}` } },
);
if (res.status === 410) {
cursor = null;
await db.setCursor(null);
continue;
}
const page = await res.json();
for (const event of page.data) {
if (await db.insertIfAbsent("processed_events", { id: event.id })) await queue.push(event);
}
if (page.data.length) {
cursor = page.next_cursor;
await db.setCursor(cursor);
}
if (!page.has_more) return; // do not spin on an empty head
}
}
Four things that code does deliberately. It returns 204 for a duplicate rather than a 409, because a 409 is in the retryable set and there is nothing to retry. It treats 410 as “restart without a cursor” rather than as an error to alarm on. It advances the cursor only when the page had rows, which is the null-cursor trap from earlier. And it writes the dedup row before the queue push, so the crash window produces a redelivery rather than a silent drop.
Run the sweep every few minutes if you have a human waiting, hourly if you do not. The only requirement is that it runs more often than your endpoint’s realistic outage, because after the eighth attempt the push path has nothing left to give you and the cursor is the record that remains. The endpoints, scopes and error codes used above are the deployed surface described on the developer overview; start from the quickstart if you have not made the first call yet.
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.
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.
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.