# Make your email agent idempotent against duplicate webhooks

> Source: <https://dev.to/mqasimca/make-your-email-agent-idempotent-against-duplicate-webhooks-521f>
> Published: 2026-07-17 21:18:22+00:00

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": "<NYLAS_APPLICATION_ID>",
    "object": {
      "id": "<MESSAGE_ID>",
      "grant_id": "<NYLAS_GRANT_ID>",
      "object": "message",
      "subject": "Can you resend my invoice?"
    }
  }
}
```

There are two ids in there, and they answer two different questions.

The **top-level id** (here,

`5da3ec1e-…`

) is the `id`

are the same notification, redelivered. The **inner data.object.id** is the

`message.created`

, then `message.updated`

events as labels and read-state change — each with its own top-level `id`

but the `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:

`id`

:`id`

(`data.object.id`

):The data plane is a grant, so creating one is one call. Two angles, as always — the HTTP call and the CLI.

Create via `POST /v3/connect/custom`

with `"provider": "nylas"`

:

```
curl --request POST \
  --url "https://api.us.nylas.com/v3/connect/custom" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "provider": "nylas",
    "name": "Support Bot",
    "settings": { "email": "support@yourcompany.com" }
  }'
```

The CLI wraps that, auto-creating the `nylas`

connector and a default workspace plus policy if they don't exist yet:

```
nylas agent account create support@yourcompany.com --name "Support Bot"
```

No refresh token, no OAuth dance — 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.

Now the webhook. It's app-scoped, so you create it once at the top-level `/v3/webhooks`

, not under the grant:

```
curl --request POST \
  --url "https://api.us.nylas.com/v3/webhooks" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "trigger_types": ["message.created"],
    "webhook_url": "https://api.yourcompany.com/webhooks/nylas",
    "description": "Agent inbound mail"
  }'
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, which is the next step. (`nylas webhook verify`

and `nylas webhook server`

are 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.)

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.

``` python
import crypto from "node:crypto";

function verifySignature(rawBody, header, secret) {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(rawBody, "utf8")
    .digest("hex");

  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(header ?? "", "hex");

  // timingSafeEqual throws on unequal lengths — guard first.
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}
```

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.

Here's 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.

```
app.post(
  "/webhooks/nylas",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const raw = req.body; // Buffer, untouched
    if (!verifySignature(raw, req.get("X-Nylas-Signature"), WEBHOOK_SECRET)) {
      return res.status(401).end();
    }

    res.status(200).end(); // ack inside 10s, then work

    const notification = JSON.parse(raw.toString("utf8"));
    queueMicrotask(() => process(notification)); // or push to a real queue
  },
);
```

The `process`

step is where dedup lives — the primary key, persisted atomically.

The store has one job: tell you whether you've seen a notification `id`

before, 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.

Redis `SET … NX`

is the canonical move — write only if absent, with a TTL so the store doesn't grow forever:

``` js
import { createClient } from "redis";
const redis = createClient();
await redis.connect();

// true the FIRST time this id is seen, false on every duplicate.
async function isFirstDelivery(notificationId) {
  const result = await redis.set(`wh:${notificationId}`, "1", {
    NX: true,
    EX: 86400, // 24h: a redelivery hours later still gets caught
  });
  return result === "OK";
}

async function process(notification) {
  if (notification.type !== "message.created") return;
  if (!(await isFirstDelivery(notification.id))) return; // duplicate, skip
  await handleMessage(notification);
}
```

If your durable store is Postgres rather than Redis, the equivalent is `INSERT … ON CONFLICT DO NOTHING`

and checking the affected row count:

```
INSERT INTO processed_webhooks (notification_id, received_at)
VALUES ($1, now())
ON CONFLICT (notification_id) DO NOTHING;
-- rowCount === 1 → first time; 0 → already seen
```

Either 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`

is far more likely a bug than a retry, and you'd rather it surface in logs than be silently swallowed.

Dedup on the notification `id`

covers 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.

The 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:

``` js
async function handleMessage(notification) {
  const msg = notification.data.object;

  // Never reply to the agent's own outbound — that fires message.created too.
  if (msg.from?.[0]?.email === AGENT_EMAIL) return;

  // Don't trust the payload for the body. Branch on truncation, fetch by id.
  const full =
    notification.type === "message.created.truncated"
      ? await fetchMessage(msg.grant_id, msg.id)
      : msg;

  await replyOnce(full);
}
```

That truncation branch matters. A `message.created`

over ~1 MB arrives as `message.created.truncated`

with the body dropped. It still carries a notification `id`

, so dedup works fine, but you have to re-fetch with `GET /v3/grants/{grant_id}/messages/{message_id}`

to 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:

```
curl --request GET \
  --url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/<MESSAGE_ID>" \
  --header "Authorization: Bearer <NYLAS_API_KEY>"
nylas email read <message-id>
```

Atomic 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.

A per-thread lock closes it. Serialize the reply path on `thread_id`

so only one worker is ever composing a reply for a given thread:

``` js
async function replyOnce(msg) {
  const lock = await acquireLock(`thread:${msg.thread_id}`, { ttlMs: 30_000 });
  if (!lock.acquired) return; // another worker owns this thread; let it handle the burst

  try {
    // Re-check inside the lock — state may have changed while we waited.
    if (await agentAlreadyReplied(msg.thread_id)) return;
    await generateAndSendReply(msg);
  } finally {
    await lock.release();
  }
}
```

The 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.

When the reply finally goes out, it's a normal grant-scoped send. `nylas email reply`

preserves the thread by setting `reply_to_message_id`

for you:

```
curl --request POST \
  --url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/send" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "reply_to_message_id": "<MESSAGE_ID>",
    "to": [{ "email": "customer@example.com" }],
    "subject": "Re: Can you resend my invoice?",
    "body": "Here is your invoice again — let me know if anything looks off."
  }'
js
nylas email reply <message-id> --body "Here is your invoice again — let me know if anything looks off."
```

It'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:

`id`

`200`

.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.

`challenge`

handshake.`nylas agent`

, `nylas webhook`

, and `nylas email`

.When this post is published, link AI agents and crawlers to the retrieval-ready version on `cli.nylas.com`

:
