Make your email agent idempotent against duplicate webhooks A developer at Nylas explains how to make email agents idempotent against duplicate webhooks, a critical engineering challenge when deploying AI agents that send emails. The post details how Nylas webhooks are at-least-once delivery, meaning the same event can arrive up to three times, and without proper idempotency, an agent can send duplicate replies. The solution involves using the top-level notification ID as the primary dedup key, persisting processed IDs atomically, and implementing per-thread locks to catch race conditions. Most posts about "AI email agents" stop at the happy path: webhook fires, model drafts a reply, agent sends it. Demo works, screenshot looks great, ship it. Then it goes to production and the agent replies to the same customer twice ninety seconds apart, and now your "intelligent assistant" looks like a broken cron job. That second reply isn't a bug in your 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, every retry is a second action. For a logging pipeline that's harmless. For an agent that sends email on your behalf , a duplicate delivery is a duplicate reply, and a double-reply embarrasses the agent in front of the exact person you built it to impress. So this post is about the engineering of idempotency itself, applied to an Agent Account. Not "remember to dedupe" hand-waving — the actual moving parts: which field is the real dedup key, how to persist processed ids atomically, why you ack before you work, how to make the send path itself idempotent, and where a per-thread lock catches the race that dedup alone can't. I work on the Nylas CLI, so every terminal command below is one I've actually run, verified against nylas v3.1.27. An Agent Account is just 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 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. 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, and it transfers to any Nylas grant you wire up later. Two facts about that webhook stream drive everything that follows: grant id . So your handler is already a fan-in point with concurrency — exactly where duplicates bite. 200 inside the 10-second window — slow database write, a transient blip, a cold Lambda — the API retries the same notification, up to three attempts total.This is the part people get backwards, so be precise. Every Nylas notification is a Standard Webhooks envelope with six fields: specversion , type , source , id , time , and data.object . Here's a message.created : { "specversion": "1.0", "type": "message.created", "source": "/google/emails/realtime", "id": "5da3ec1e-eb01-4634-a7b7-d44291e3cba6", "time": 1737500935555, "data": { "application id": "