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.
{ "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": "Your order shipped" } }}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.
import { createClient } from "redis";const redis = createClient();await redis.connect();
// Returns true the FIRST time it sees an id, false on every duplicate.async function isFirstDelivery(notificationId) { // SET NX writes only if the key is absent; EX expires it after 24 hours. const result = await redis.set(`wh:${notificationId}`, "1", { NX: true, EX: 86400, }); return result === "OK";}
app.post("/webhooks/nylas", async (req, res) => { res.status(200).end(); // ack inside 10s, then work const notification = JSON.parse(req.body); if (!(await isFirstDelivery(notification.id))) return; // duplicate, skip await handleEvent(notification); // runs 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.
import jsonimport redis
r = redis.Redis()
def is_first_delivery(notification_id: str) -> bool: # nx=True writes only if absent; ex=86400 expires the key after 24 hours. return r.set(f"wh:{notification_id}", "1", nx=True, ex=86400) is True
@app.post("/webhooks/nylas")def handle(): raw = request.get_data() # Ack first, then dedupe before any side effect runs. notification = json.loads(raw) if not is_first_delivery(notification["id"]): return "", 200 # duplicate, already processed handle_event(notification) return "", 200How 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.
import Stripe from "stripe";const stripe = new Stripe(process.env.STRIPE_KEY);
async function chargeOnce(notification) { // Deterministic key: same event + action always yields the same key. const key = `nylas:${notification.id}:charge`; await stripe.charges.create( { amount: 500, currency: "usd", customer: customerFor(notification) }, { idempotencyKey: key }, // Stripe returns the first result for 24h );}import stripe
stripe.api_key = os.environ["STRIPE_KEY"]
def charge_once(notification): # Deterministic key: identical for every retry of this notification. key = f"nylas:{notification['id']}:charge" stripe.Charge.create( amount=500, currency="usd", customer=customer_for(notification), idempotency_key=key, # Stripe replays the first result for 24h )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 generatemessage.createdand severalmessage.updatednotifications, each with its ownidbut the samedata.object.id. Keying on the object id would wrongly drop the legitimate updates. The 64-character-safe key to store is the notificationid. - Ordering isn’t guaranteed. Notifications can arrive out of order, so a
message.updatedmay land before itsmessage.created. Dedupe and ordering are separate problems: an idempotency check stops double-processing but won’t sequence events for you. Handle an.updatedor.deletedthat arrives first. - Truncated payloads share the event’s identity. A
message.createdover 1 MB arrives asmessage.created.truncatedwith no body. It still carries a notificationid, so dedupe works, but re-query the object with a Get Message request to recover the dropped content. - Ack before you dedupe. Return
200 OKwithin 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.
What’s next
Section titled “What’s next”- Retry and debug failed webhooks for redelivery, the 10-second timeout, and debugging a quiet endpoint
- Verify webhook signatures to authenticate every notification before you store its
id - Get real-time updates with webhooks to subscribe one endpoint to messages, opens, replies, and calendar events
- Using webhooks with Nylas for setup, the
challengehandshake, retries, and failure handling