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