Skip to content
Skip to main content

Handle duplicate webhook deliveries

Last updated:

Your webhook handler sent one welcome email, then sent a second one ninety seconds later for the same message.created. Nothing is broken. The API delivered the same notification twice on purpose, and your handler treated each copy as a fresh event. The fix isn’t on the Nylas side. It’s a few lines of idempotency in the code that receives the POST.

This recipe covers why the same event reaches you more than once, how to dedupe on the notification id, and how to add an idempotency key when the side effect lands in a system you don’t control. It complements Retry and debug failed webhooks, which handles redelivery from the sender’s side.

Why do duplicate webhook deliveries happen?

Section titled “Why do duplicate webhook deliveries happen?”

Duplicate deliveries happen because the API guarantees at-least-once delivery, never exactly once. When your endpoint doesn’t return 200 OK within the 10-second window, the API retries the same notification up to two more times, for 3 attempts total. A handler that does its work before acknowledging can run twice for one event.

The most common trigger is a slow 200. Your endpoint finishes the database write, the email send, and a customer-record update, and only then returns 200. If that takes 11 seconds, the API has already given up and queued a retry. The retry arrives carrying the exact same payload, and your handler processes it a second time because nothing told it the work was already done.

What field identifies a duplicate notification?

Section titled “What field identifies a duplicate notification?”

Every notification carries a top-level id that stays constant across all 3 delivery attempts of the same event. Two POSTs with the same id are the same notification redelivered, so that field is your deduplication key. The surrounding envelope follows the Standard Webhooks shape, with these 6 fields: specversion, type, source, id, time, and a data.object.

The payload below is a message.created notification. The top-level id identifies the delivery; the inner data.object.id identifies the message itself. Dedupe on the top-level id, because that’s the value the API repeats on every retry of one event. The time field is a Unix millisecond timestamp of when the event fired.

How do I deduplicate webhook notifications?

Section titled “How do I deduplicate webhook notifications?”

Record the notification id in a fast store the moment a delivery arrives, and skip any id you’ve already seen. A single atomic write that both checks and sets in one step is the safe pattern, because 2 retries can hit two server instances at the same millisecond. Expire each key after 24 hours to cap the store size.

The Node.js handler below uses a Redis SET with NX (set only if absent) and EX 86400 (24-hour expiry). SET NX returns null when the key already exists, which means a duplicate, so the handler exits before doing any work. Verify the signature first so you never store an id from a forged request. The real work runs exactly once per notification id.

The Python version follows the same shape with redis-py. The set call passes nx=True and ex=86400, returning True only on the first write for that key. A None return means the id is already recorded, so the handler stops. This keeps your business logic correct whether a notification arrives one time or three.

How do I make side effects idempotent in external systems?

Section titled “How do I make side effects idempotent in external systems?”

When the side effect lands in a system you don’t own, a notification id in Redis isn’t enough. A crash between recording the id and finishing the call leaves a half-done action. Pass an idempotency key derived from the notification id to the downstream API so that service dedupes the request itself, even on a retry.

Most payment and messaging APIs accept an idempotency key for exactly this reason. Stripe reads an Idempotency-Key header and returns the original result for 24 hours instead of charging twice. Derive the key deterministically from the notification id plus the action name so the same event always produces the same key. The example sends one charge per thread.replied, safe against both webhook retries and your own retries.

Things to know about deduplicating Nylas webhooks

Section titled “Things to know about deduplicating Nylas webhooks”

Deduplication on the notification id covers retries, but a reliable handler accounts for a few related behaviors that also produce repeat or surprising work. The API attempts each notification up to three times across a 10-to-20-minute window, and treats delivery as best-effort rather than exactly once. Plan for the four cases below.

  • Dedupe on the top-level id, not the object id. A single message can generate message.created and several message.updated notifications, each with its own id but the same data.object.id. Keying on the object id would wrongly drop the legitimate updates. The 64-character-safe key to store is the notification id.
  • Ordering isn’t guaranteed. Notifications can arrive out of order, so a message.updated may land before its message.created. Dedupe and ordering are separate problems: an idempotency check stops double-processing but won’t sequence events for you. Handle an .updated or .deleted that arrives first.
  • Truncated payloads share the event’s identity. A message.created over 1 MB arrives as message.created.truncated with no body. It still carries a notification id, so dedupe works, but re-query the object with a Get Message request to recover the dropped content.
  • Ack before you dedupe. Return 200 OK within the 10-second budget first, then run the dedupe check on a background worker. Doing the Redis lookup on the request path adds latency that can push a slow handler past the timeout and trigger the retry you’re trying to absorb.

For the retry status codes and failure thresholds behind these behaviors, see Retry a webhook and the webhook notification schemas for every trigger’s exact data.object.