Duplicate webhook deliveries can cause AI email agents to send multiple replies — here's how to build idempotency into your handler using notification IDs and atomic acknowledgements.
Most discussions about "AI email agents" focus on the ideal scenario: a webhook fires, a language model drafts a response, and the agent sends it. The demo looks smooth, the screenshot is impressive, and you ship it. But in production, the agent can reply to the same customer twice within ninety seconds, turning your intelligent assistant into an embarrassment. That second reply isn't a bug in the model; it's a property of the delivery system, and it's guaranteed to happen eventually.
Nylas webhooks are at-least-once: the same event can arrive up to three times. If your handler treats every POST as a fresh event, each retry triggers a second action. For a logging pipeline that's harmless, but for an agent that sends email on your behalf, a duplicate delivery means a duplicate reply — and a double-reply undermines the very impression you built the agent to make.
This article walks through the engineering of idempotency for an Agent Account, covering which field is the real dedup key, how to persist processed IDs atomically, why you acknowledge before you work, and how to make the send path itself idempotent. The examples are based on the Nylas CLI, verified against nylas v3.1.27.
What an Agent Account changes (and what it doesn't)
An Agent Account is simply a grant. It has a grant_id and works with every grant-scoped endpoint — Messages, Drafts, Threads, Folders — exactly like a connected Gmail or Microsoft account. The difference is that it's an inbox the agent owns: support@yourcompany.com is the agent, not a human whose inbox the agent borrows. Inbound mail to that address fires the standard message.created webhook, the agent reads it, and the agent replies from its own address.
There's nothing new to learn on the data plane. That's the whole point of the grant abstraction — the idempotency work below is plain webhook-handling discipline that transfers to any Nylas grant you wire up later.
Two facts about that webhook stream drive everything that follows. First, webhooks are application-scoped, not grant-scoped. You subscribe once at the app level, and events for every grant land on the same endpoint, each payload carrying a grant_id. So your handler is already a fan-in point with concurrency — exactly where duplicates bite. Second, delivery is at-least-once. If your endpoint doesn't return 200 within the 10-second window — due to a slow database write, a transient blip, or a cold Lambda — the API retries the same notification up to three times total.
The two dedup keys, and which one is primary
Every Nylas notification is a Standard Webhooks envelope with six fields: specversion, type, source, id, time, and data.object. There are two IDs in that payload, and they answer two different questions.
The top-level id (for example, 5da3ec1e-eb01-4634-a7b7-d44291e3cba6) is the notification id. It stays constant across all three delivery attempts of one event. Two POSTs with the same top-level id are the same notification, redelivered. That is your primary webhook-delivery dedup key. It answers: "have I already processed this delivery?"
The inner data.object.id is the message id. It identifies the email, not the delivery. A single message legitimately produces several different notifications over its life — one message.created, then message.updated events as labels and read-state change — each with its own top-level id but the same data.object.id. If you dedupe on the message id, you'd wrongly drop those legitimate updates.
So why mention the message id at all? Because it's a useful secondary guard. It answers a different question: "have I already acted on this message?" Two distinct notifications (say, a message.created and a later event referencing the same email) can both push your agent toward replying. Deduping on the notification id won't catch that — they're genuinely different deliveries. A "have I replied to this message already?" check, keyed on data.object.id, will.
The rule of thumb: use the notification id as your primary dedup key to absorb at-least-once retries (always required), and the message id as a secondary guard to stop the agent acting twice on one message across two different events (add it on the reply path).
Provision the Agent Account and the webhook
Creating an Agent Account is straightforward. Use the POST /v3/connect/custom endpoint with "provider": "nylas", or the CLI command `nylas agent account create support@yourcompany.com --name "Support Bot"`. No refresh token or OAuth dance is needed — the email lives on a domain you've registered. Grab the grant_id from the response; it's the identifier on every grant-scoped call below.
The webhook is application-scoped, so you create it once at the top-level /v3/webhooks. Use the curl command or `nylas webhook create --url https://api.yourcompany.com/webhooks/nylas --triggers message.created --description "Agent inbound mail"`. Save the webhook secret from the create response — you need it to verify signatures.
Verify the signature before you trust anything
Your dedup store should never hold an id from a forged request, so signature verification comes first. Nylas signs each delivery with X-Nylas-Signature — a hex HMAC-SHA256 of the raw request body using your webhook secret. Two things matter: hash the raw body (not the re-serialized JSON — re-encoding changes bytes and breaks the hash), and guard the buffer lengths before a constant-time compare, because crypto.timingSafeEqual throws on a length mismatch.
Mount your handler so the raw body is preserved — express.raw({ type: "application/json" }), or read the stream yourself. Once you JSON.parse before hashing, the bytes are gone.
Ack fast, then dedupe, then work
The ordering that trips people up: the 10-second window is for the 200, not for your work. If you do the database write, the model call, and the send before you acknowledge, a slow turn pushes you past the timeout and triggers the very retry you're trying to absorb. So: return 200 immediately, then do everything else on a background path.
After acknowledging, check your dedup store for the notification id. If it's already present, drop the event silently. If not, atomically insert the id (with a TTL to avoid unbounded storage) and proceed to process the message. Finally, on the send path, check whether you've already replied to that message id — this secondary guard catches races that dedup alone can't.
By following these steps — using the correct dedup keys, verifying signatures, and acknowledging before processing — your AI email agent can handle duplicate webhooks gracefully and maintain a professional reputation with every customer interaction.