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 situationUseWhy
Human waiting on the replyWebhook + cursorPoll interval is a floor on response time you cannot lower cheaply.
You are provisioning from CI with an API keyCursor onlyEndpoint creation refuses every non-human principal. See the next section.
Laptop, CI, or anything without a public HTTPS originCursor onlyRegistration rejects non-public URLs, and re-checks DNS at delivery.
Credential scoped to a subset of inboxesCursor onlyThe cursor inherits the key’s inbox list. An endpoint gets the project.
You need event orderingCursorOnly the polled representation carries a sequence number.
Batch job that runs hourly anywayCursor onlyA webhook adds an endpoint to operate and gains you nothing.
Your handler is behind a WAF or a framework with a 4xxCursor as backstopA stray 4xx is terminal. The event is gone from the push path.
You rotate the webhook secret, or disable the endpointCursor to catch upQueued deliveries are cancelled and never replay.
High volume, and you care about wasted requestsWebhook + cursorPoll 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.

  1. A 2xx from your endpoint marks the delivery succeeded and stops.
  2. The eighth attempt is the last one regardless of outcome. The delivery moves to exhausted.
  3. 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 exhausted and 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:

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

All guides · Documentation · Developers