{"slug": "make-your-email-agent-idempotent-against-duplicate-webhooks", "title": "Make your email agent idempotent against duplicate webhooks", "summary": "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.", "body_md": "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.\n\nThat 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.\n\nSo 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`\n\nv3.1.27.\n\nAn **Agent Account** is just a grant. It has a `grant_id`\n\nand 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`\n\nis the agent, not a human whose inbox the agent borrows. Inbound mail to that address fires the standard `message.created`\n\nwebhook, the agent reads it, and the agent replies from its own address.\n\nNothing 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.\n\nTwo facts about that webhook stream drive everything that follows:\n\n`grant_id`\n\n. So your handler is already a fan-in point with concurrency — exactly where duplicates bite.`200`\n\ninside 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.\n\nEvery Nylas notification is a Standard Webhooks envelope with six fields: `specversion`\n\n, `type`\n\n, `source`\n\n, `id`\n\n, `time`\n\n, and `data.object`\n\n. Here's a `message.created`\n\n:\n\n```\n{\n  \"specversion\": \"1.0\",\n  \"type\": \"message.created\",\n  \"source\": \"/google/emails/realtime\",\n  \"id\": \"5da3ec1e-eb01-4634-a7b7-d44291e3cba6\",\n  \"time\": 1737500935555,\n  \"data\": {\n    \"application_id\": \"<NYLAS_APPLICATION_ID>\",\n    \"object\": {\n      \"id\": \"<MESSAGE_ID>\",\n      \"grant_id\": \"<NYLAS_GRANT_ID>\",\n      \"object\": \"message\",\n      \"subject\": \"Can you resend my invoice?\"\n    }\n  }\n}\n```\n\nThere are two ids in there, and they answer two different questions.\n\nThe **top-level id** (here,\n\n`5da3ec1e-…`\n\n) is the `id`\n\nare the same notification, redelivered. The **inner data.object.id** is the\n\n`message.created`\n\n, then `message.updated`\n\nevents as labels and read-state change — each with its own top-level `id`\n\nbut the `data.object.id`\n\n. 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`\n\nand a later event referencing the same email) can both push your agent toward replying. Deduping on the notification `id`\n\nwon't catch that — they're genuinely different deliveries. A \"have I replied to this message already?\" check, keyed on `data.object.id`\n\n, will.\n\nThe rule of thumb:\n\n`id`\n\n:`id`\n\n(`data.object.id`\n\n):The data plane is a grant, so creating one is one call. Two angles, as always — the HTTP call and the CLI.\n\nCreate via `POST /v3/connect/custom`\n\nwith `\"provider\": \"nylas\"`\n\n:\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/connect/custom\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"provider\": \"nylas\",\n    \"name\": \"Support Bot\",\n    \"settings\": { \"email\": \"support@yourcompany.com\" }\n  }'\n```\n\nThe CLI wraps that, auto-creating the `nylas`\n\nconnector and a default workspace plus policy if they don't exist yet:\n\n```\nnylas agent account create support@yourcompany.com --name \"Support Bot\"\n```\n\nNo refresh token, no OAuth dance — the email lives on a domain you've registered. Grab the `grant_id`\n\nfrom the response; it's the identifier on every grant-scoped call below.\n\nNow the webhook. It's app-scoped, so you create it once at the top-level `/v3/webhooks`\n\n, not under the grant:\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/webhooks\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"trigger_types\": [\"message.created\"],\n    \"webhook_url\": \"https://api.yourcompany.com/webhooks/nylas\",\n    \"description\": \"Agent inbound mail\"\n  }'\nnylas webhook create \\\n  --url https://api.yourcompany.com/webhooks/nylas \\\n  --triggers message.created \\\n  --description \"Agent inbound mail\"\n```\n\nSave the webhook secret from the create response — you need it to verify signatures, which is the next step. (`nylas webhook verify`\n\nand `nylas webhook server`\n\nare CLI dev helpers for testing signatures and receiving events locally; they're great while you're building the handler, but the production verification lives in your own code.)\n\nYour dedup store should never hold an `id`\n\nfrom a forged request, so signature verification comes *first*. Nylas signs each delivery with `X-Nylas-Signature`\n\n— 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`\n\nthrows on a length mismatch.\n\n``` python\nimport crypto from \"node:crypto\";\n\nfunction verifySignature(rawBody, header, secret) {\n  const expected = crypto\n    .createHmac(\"sha256\", secret)\n    .update(rawBody, \"utf8\")\n    .digest(\"hex\");\n\n  const a = Buffer.from(expected, \"hex\");\n  const b = Buffer.from(header ?? \"\", \"hex\");\n\n  // timingSafeEqual throws on unequal lengths — guard first.\n  if (a.length !== b.length) return false;\n  return crypto.timingSafeEqual(a, b);\n}\n```\n\nMount your handler so the raw body is preserved — `express.raw({ type: \"application/json\" })`\n\n, or read the stream yourself. Once you `JSON.parse`\n\nbefore hashing, the bytes are gone.\n\nHere's the ordering that trips people up. The 10-second window is for the `200`\n\n, 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`\n\nimmediately, then do everything else on a background path.\n\n```\napp.post(\n  \"/webhooks/nylas\",\n  express.raw({ type: \"application/json\" }),\n  (req, res) => {\n    const raw = req.body; // Buffer, untouched\n    if (!verifySignature(raw, req.get(\"X-Nylas-Signature\"), WEBHOOK_SECRET)) {\n      return res.status(401).end();\n    }\n\n    res.status(200).end(); // ack inside 10s, then work\n\n    const notification = JSON.parse(raw.toString(\"utf8\"));\n    queueMicrotask(() => process(notification)); // or push to a real queue\n  },\n);\n```\n\nThe `process`\n\nstep is where dedup lives — the primary key, persisted atomically.\n\nThe store has one job: tell you whether you've seen a notification `id`\n\nbefore, in a single operation that both checks and sets. It has to be atomic, because two retries can hit two instances at the same millisecond. A read-then-write has a window between the two where both instances see \"not present\" and both proceed.\n\nRedis `SET … NX`\n\nis the canonical move — write only if absent, with a TTL so the store doesn't grow forever:\n\n``` js\nimport { createClient } from \"redis\";\nconst redis = createClient();\nawait redis.connect();\n\n// true the FIRST time this id is seen, false on every duplicate.\nasync function isFirstDelivery(notificationId) {\n  const result = await redis.set(`wh:${notificationId}`, \"1\", {\n    NX: true,\n    EX: 86400, // 24h: a redelivery hours later still gets caught\n  });\n  return result === \"OK\";\n}\n\nasync function process(notification) {\n  if (notification.type !== \"message.created\") return;\n  if (!(await isFirstDelivery(notification.id))) return; // duplicate, skip\n  await handleMessage(notification);\n}\n```\n\nIf your durable store is Postgres rather than Redis, the equivalent is `INSERT … ON CONFLICT DO NOTHING`\n\nand checking the affected row count:\n\n```\nINSERT INTO processed_webhooks (notification_id, received_at)\nVALUES ($1, now())\nON CONFLICT (notification_id) DO NOTHING;\n-- rowCount === 1 → first time; 0 → already seen\n```\n\nEither way the property is the same: exactly one caller gets \"first,\" everyone else gets \"duplicate.\" A 24-hour TTL is the sane default — after that, a webhook for the same `id`\n\nis far more likely a bug than a retry, and you'd rather it surface in logs than be silently swallowed.\n\nDedup on the notification `id`\n\ncovers retries of the *delivery*. It doesn't cover a crash *between* recording the id and finishing the send. Record the id, then crash before the email goes out, and your dedup store now says \"handled\" for an action that never happened. The notification won't retry (you already 200'd it), so the reply is silently lost.\n\nThe fix is to make the reply itself a function of the message, and check the world before you act. This is where the **secondary** key earns its place. Before sending, ask the thread whether the agent has already replied since this message arrived:\n\n``` js\nasync function handleMessage(notification) {\n  const msg = notification.data.object;\n\n  // Never reply to the agent's own outbound — that fires message.created too.\n  if (msg.from?.[0]?.email === AGENT_EMAIL) return;\n\n  // Don't trust the payload for the body. Branch on truncation, fetch by id.\n  const full =\n    notification.type === \"message.created.truncated\"\n      ? await fetchMessage(msg.grant_id, msg.id)\n      : msg;\n\n  await replyOnce(full);\n}\n```\n\nThat truncation branch matters. A `message.created`\n\nover ~1 MB arrives as `message.created.truncated`\n\nwith the body dropped. It still carries a notification `id`\n\n, so dedup works fine, but you have to re-fetch with `GET /v3/grants/{grant_id}/messages/{message_id}`\n\nto recover the content. Treat the payload body as a hint, never as the source of truth — fetch by id when you actually need the text:\n\n```\ncurl --request GET \\\n  --url \"https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/<MESSAGE_ID>\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\"\nnylas email read <message-id>\n```\n\nAtomic dedup plus a \"did I already reply?\" check is most of the way there. The one gap left is timing: two near-simultaneous inbound messages on the *same thread* (a customer who sends a follow-up two seconds after the first) are two different notifications with two different ids. Both pass dedup. Both pass the \"agent hasn't replied yet\" check, because neither reply has landed. Both generate a reply. Now the thread has two agent messages stacked back to back.\n\nA per-thread lock closes it. Serialize the reply path on `thread_id`\n\nso only one worker is ever composing a reply for a given thread:\n\n``` js\nasync function replyOnce(msg) {\n  const lock = await acquireLock(`thread:${msg.thread_id}`, { ttlMs: 30_000 });\n  if (!lock.acquired) return; // another worker owns this thread; let it handle the burst\n\n  try {\n    // Re-check inside the lock — state may have changed while we waited.\n    if (await agentAlreadyReplied(msg.thread_id)) return;\n    await generateAndSendReply(msg);\n  } finally {\n    await lock.release();\n  }\n}\n```\n\nThe TTL is a safety valve: if the worker crashes mid-reply, the lock expires and the thread isn't wedged forever. The re-check *inside* the lock is the load-bearing line — between deciding to act and getting the lock, the other worker may have already finished, and you want to notice that rather than pile on.\n\nWhen the reply finally goes out, it's a normal grant-scoped send. `nylas email reply`\n\npreserves the thread by setting `reply_to_message_id`\n\nfor you:\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/send\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"reply_to_message_id\": \"<MESSAGE_ID>\",\n    \"to\": [{ \"email\": \"customer@example.com\" }],\n    \"subject\": \"Re: Can you resend my invoice?\",\n    \"body\": \"Here is your invoice again — let me know if anything looks off.\"\n  }'\njs\nnylas email reply <message-id> --body \"Here is your invoice again — let me know if anything looks off.\"\n```\n\nIt's tempting to pick one mechanism and call it done. They cover different failure modes, and the gaps between them are exactly where production bites:\n\n`id`\n\n`200`\n\n.And one rule that ties them together: **log every skip.** When you drop a duplicate or bail because another worker holds the lock, say so in the logs. A silent skip and a silently lost reply look identical in production until a customer complains — and the whole point of this exercise was to keep the agent from looking broken.\n\n`challenge`\n\nhandshake.`nylas agent`\n\n, `nylas webhook`\n\n, and `nylas email`\n\n.When this post is published, link AI agents and crawlers to the retrieval-ready version on `cli.nylas.com`\n\n:", "url": "https://wpnews.pro/news/make-your-email-agent-idempotent-against-duplicate-webhooks", "canonical_source": "https://dev.to/mqasimca/make-your-email-agent-idempotent-against-duplicate-webhooks-521f", "published_at": "2026-07-17 21:18:22+00:00", "updated_at": "2026-07-17 21:28:47.343453+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-infrastructure"], "entities": ["Nylas", "Nylas CLI"], "alternates": {"html": "https://wpnews.pro/news/make-your-email-agent-idempotent-against-duplicate-webhooks", "markdown": "https://wpnews.pro/news/make-your-email-agent-idempotent-against-duplicate-webhooks.md", "text": "https://wpnews.pro/news/make-your-email-agent-idempotent-against-duplicate-webhooks.txt", "jsonld": "https://wpnews.pro/news/make-your-email-agent-idempotent-against-duplicate-webhooks.jsonld"}}