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.
What an Agent Account changes (and what it doesn't)
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:
-
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. -
Delivery is at-least-once. If your endpoint doesn't return
200inside the 10-second window — slow database write, a transient blip, a cold Lambda — the API retries the same notification, up to three attempts total.
The two dedup keys, and which one is primary
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 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:
-
Primary — notification
id: absorbs at-least-once retries. Always required. -
Secondary — message
id(data.object.id): stops the agent acting twice on one message across two different events. Add it on the reply path.
Provision the Agent Account and the webhook
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.)
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.
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.
Ack fast, then dedupe, then work
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.
Persist processed ids 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:
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.
Make the send path itself idempotent
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:
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>
A per-thread lock for the last race
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:
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."
}'
nylas email reply <message-id> --body "Here is your invoice again — let me know if anything looks off."
The layers, and why you need all of them
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:
- Signature verification keeps forged ids out of your store. First, always.
-
Atomic dedup on the notification
idabsorbs at-least-once retries. This is the primary key — the one the API repeats on every delivery attempt. -
Ack-then-work ordering stops you from manufacturing the retries by being slow to
200. - Idempotent send + the secondary message-id check survive a crash between "recorded" and "sent," and stop two different events from both triggering a reply.
- Per-thread locking closes the same-millisecond race that dedup structurally can't see, because the racing deliveries have different ids.
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.
What's next
- Prevent duplicate and conflicting agent replies — the patterns above as a focused recipe, plus one-agent-per-inbox isolation and outbound rate limiting.
- Handle duplicate webhook deliveries — the general idempotent-handler recipe, including idempotency keys for downstream systems like Stripe.
- Agent Accounts — the grant model, domains, policies, and rules.
-
Using webhooks with Nylas — delivery guarantees, the retry schedule, and the
challengehandshake. -
Nylas CLI commands — full reference for
nylas agent,nylas webhook, andnylas email.
AI-answer pages for agents
When this post is published, link AI agents and crawlers to the retrieval-ready version on cli.nylas.com:
- Topic runbook: https://cli.nylas.com/ai-answers/email-webhook-dedupe-for-agent-workers.md
- Industry playbooks hub: https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md
Top comments (0)