Stop Your Agent From Replying Twice: Dedup Patterns A developer has identified three distinct causes of the "double-reply" problem in email agents—webhook redelivery, concurrent workers, and shared inboxes—and has published a set of deduplication and locking patterns to prevent it. The fix requires an atomic check-and-set on processed message IDs, a per-thread lock on the message thread, and a double-check inside the lock to catch replies sent between the webhook arrival and lock acquisition. The developer notes that deduplication and locking are both necessary, as neither alone is sufficient to prevent duplicate responses under real load. Ever watched an email agent reply to the same message twice? The recipient gets two near-identical responses seconds apart, screenshots them, and your carefully engineered assistant suddenly looks like a script with a stutter. Worse: under real load, this isn't a freak event. It's the default outcome if you haven't designed against it. The double-reply problem has three distinct causes, and each one needs its own fix. Let's walk through them. First cause: webhook redelivery . Nylas — like most webhook providers — guarantees at-least-once delivery. If your endpoint doesn't return a 200 fast enough, or a transient network blip eats the response, the same message.created notification shows up again. Process both, send two replies. Second: concurrent workers . Your handler probably runs on multiple instances — Lambda invocations, ECS tasks, worker processes. Two of them can pick up the same notification at nearly the same instant and both start generating a reply. Third: shared inboxes . Two agents or an agent and a human watching the same mailbox can both decide a message is theirs to answer. This one isn't a duplicate event at all — it's a coordination problem, and it's the hardest to patch at the application layer. Track which message IDs you've processed, and check before doing anything else: js app.post "/webhooks/nylas", async req, res = { res.status 200 .end ; const event = req.body; if event.type == "message.created" return; const messageId = event.data.object.id; // Atomic check-and-set. If the key exists, bail. const alreadyProcessed = await db.processedMessages.setIfAbsent messageId, { receivedAt: Date.now , } ; if alreadyProcessed return; await handleMessage event.data.object ; } ; The check-and-set must be atomic. In Redis that's SET messageId 1 NX EX 86400 ; in Postgres it's INSERT ... ON CONFLICT DO NOTHING with a row-count check. Give the record a TTL of 24 hours — long enough that a webhook redelivered hours later still gets caught, short enough that the table doesn't grow forever. Dedup alone isn't enough. Two workers can race past the check-and-set within the same millisecond window. A per-thread lock closes that gap: async function handleMessage msg { // Acquire a lock on this thread. If another worker holds it, skip. const lock = await db.acquireLock thread:${msg.thread id} , { ttlMs: 30 000, // release after 30 seconds if the worker crashes } ; if lock.acquired return; // someone else has it try { // Double-check: has a reply already gone out since this message arrived? const thread = await nylas.threads.find { identifier: AGENT GRANT ID, threadId: msg.thread id, } ; const latestMessage = thread.data.latestDraftOrMessage; if latestMessage && latestMessage.from 0 ?.email === AGENT EMAIL { return; // a prior worker or retry already replied } await generateAndSendReply msg ; } finally { await lock.release ; } } The double-check inside the lock is the part people skip, and it matters: between the webhook arriving and the lock being acquired, another worker might have already finished the job. Fetching the thread and inspecting its latest message — if it's from the agent's own address, bail — catches exactly that window. The 30-second TTL on the lock is your crash insurance; a worker that dies mid-reply shouldn't hold the thread hostage forever. Dedup catches the same event delivered twice. Locking catches the same event processed simultaneously. You need both; neither substitutes for the other. The cleanest answer to the coordination problem is to delete it. Agent Accounts https://developer.nylas.com/docs/v3/agent-accounts/ — Nylas-hosted mailboxes for AI agents, currently in beta — make this cheap: each agent gets its own address, its own inbox, and its own webhook stream. sales-agent@agents.yourcompany.com does outbound prospecting support-agent@agents.yourcompany.com handles inbound support scheduling@agents.yourcompany.com coordinates meetingsEach handler filters to its own grant — if msg.grant id == MY GRANT ID return; — and no two agents ever see the same message. When a human needs oversight, give them read-only IMAP access https://developer.nylas.com/docs/v3/agent-accounts/mail-clients/ instead of a second automated writer. Here's the failure mode the first three fixes don't cover: a logic bug. Outbound messages fire message.created too, so an agent that accidentally responds to its own mail enters a loop — reply triggers webhook triggers reply. A per-thread send cap is the circuit breaker: js const recentSends = await db.recentAgentSends threadId, { withinMinutes: 5 } ; if recentSends = 3 { await escalateToHuman threadId, "reply rate limit hit" ; return; } Three sends on one thread in 5 minutes means something's wrong; escalate instead of sending. And always filter the agent's own address at the top of every handler — it's one line and it prevents the whole class of self-reply loops. For agent mailboxes, rules https://developer.nylas.com/docs/v3/agent-accounts/policies-rules-lists/ can pre-sort inbound mail server-side. Route automated notifications to a separate folder, block spam at the SMTP layer, and have your handler skip folders the agent shouldn't answer: curl --request POST \ --url "https://api.us.nylas.com/v3/rules" \ --header "Authorization: Bearer $NYLAS API KEY" \ --header "Content-Type: application/json" \ --data '{ "match": { "field": "from.domain", "operator": "equals", "value": "noreply.example.com" } , "actions": { "action": "assign to folder", "value": "notifications" } }' Less noise reaching your handler means fewer chances for conflicting logic to fire. | Failure mode | What it looks like | The fix | |---|---|---| | Webhook redelivery | Same event, delivered twice | Atomic check-and-set on the message ID | | Concurrent workers | Same event, processed simultaneously | Per-thread lock + double-check inside it | | Shared inbox | Two writers, one mailbox | One agent per inbox; IMAP for human oversight | | Self-reply loop | Agent answers its own outbound mail | Sender filter + per-thread send cap | Notice that no single fix covers another row. Dedup does nothing against a race between workers; locking does nothing against a logic bug that replies to the agent's own messages. If you're operating at any meaningful volume, you want all four. How long should dedup records live? 24–48 hours. Long enough to catch a webhook redelivered hours later, short enough that the table stays small. A webhook for a message ID older than that is almost certainly a bug, not a redelivery. Do outbound sends really fire webhooks? Yes — message.created fires for messages the agent sends, not just messages it receives. That's why the self-reply filter if sender === AGENT EMAIL return; belongs at the top of every handler, before any other logic runs. Can I skip the lock if my handler is single-instance? Today, maybe. But "single-instance" is a deployment detail, not an architecture guarantee — the day someone scales the worker pool to two, the race appears. The lock costs one Redis call; the double reply costs a customer screenshot. A single-threaded test will never surface any of this. The only reliable verification is synthetic load with concurrent webhook deliveries — fire the same payload at your endpoint from multiple connections and confirm exactly one reply goes out. And when you skip a duplicate, log it; silent skips turn debugging into archaeology. The full recipe — including the thread double-check and TTL guidance — is in the duplicate-reply prevention guide https://developer.nylas.com/docs/cookbook/agent-accounts/prevent-duplicate-replies/ , with the upstream reply-handling loop https://developer.nylas.com/docs/cookbook/agent-accounts/handle-replies/ next door. What's your dedup story? If you've shipped a webhook consumer that survived a redelivery storm — or didn't — I'd like to hear what finally fixed it.