# Handle duplicate webhook deliveries

Source: https://developer.nylas.com/docs/cookbook/use-cases/build/handle-duplicate-webhooks/

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](/docs/cookbook/use-cases/build/retry-failed-webhooks/), which handles redelivery from the sender's side.

## 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?

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](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md) 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.

```json
{
  "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?

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

```js [dedupeStore-Node.js]

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.

```python
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 "", 200
```

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

```js [idempotencyKey-Node.js]

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
  );
}
```

```python
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

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](/docs/v3/notifications/#retry-a-webhook) and the [webhook notification schemas](/docs/reference/notifications/) for every trigger's exact `data.object`.

## What's next

- [Retry and debug failed webhooks](/docs/cookbook/use-cases/build/retry-failed-webhooks/) for redelivery, the 10-second timeout, and debugging a quiet endpoint
- [Verify webhook signatures](/docs/cookbook/use-cases/build/verify-webhook-signatures/) to authenticate every notification before you store its `id`
- [Get real-time updates with webhooks](/docs/cookbook/use-cases/build/realtime-webhooks/) to subscribe one endpoint to messages, opens, replies, and calendar events
- [Using webhooks with Nylas](/docs/v3/notifications/) for setup, the `challenge` handshake, retries, and failure handling