# Duplicate webhooks: four causes, and idempotency fixes one

> The same charge lands twice and the reflex is to reach for idempotency. Four different situations look identical from inside your handler, and they are fixed in four different places.

- Author: Quentin Mousset
- Published: 2026-08-31
- Canonical: https://repost.sh/blog/duplicate-webhooks-four-causes

![A branching diagram on black. A provider cloud feeds one indigo box, event evt_a1b2, which splits into four numbered branches. Retry sends two arrows to the same box marked your API, the second annotated attempt 2. Fan-out sends one arrow each to API and billing. Replay sends a dashed arrow labelled forward_type: REPLAY. Two events shows a second box, evt_c3d4, alongside the first. Down the right edge in coral, where each one is repaired: your handler, your topology, nothing, your dedupe key. Under a dashed line: an event is not a delivery.](/images/blog/figure-duplicate-events.webp)

The same charge is in your database twice. Support noticed before you did. The reflex is to reach
  for an idempotency key and move on, and it is the right move in exactly one of the four
  cases below. The other three produce two rows where you expected one and are fixed somewhere else
  entirely.

## An event is not a delivery

One inbound webhook is one event. What happens next is deliveries, plural.
A single event can produce many of them: one per destination, one per retry,
one per replay you start by hand. Every one of those is a separate `POST` to a
separate handler, and every one of them can be the second row you are staring
at.

So "I got the event twice" is almost never what happened. Something got
delivered twice. Which thing, and to whom, is the entire diagnosis.

## Four things that look identical from inside your handler

### 1. A retry, because your endpoint failed the first time

Your handler returned a `500`, or timed out, or refused the connection, and the
forwarder tried again. Same event, second attempt.

```text
attempt:[2 TO *]
```

This is not a malfunction, it is the delivery guarantee working. Retries fire
on connection failures, timeouts, `408`, `429` and `5xx` ([what gets retried
and what does not](/docs/forwarders/configuration)). A `422` is final, because
sending an unchanged payload back into the same rejection is just a slower way
to get the same answer.

The fix is entirely inside your handler. Nothing about the transport should
change.

### 2. Your own fan-out

Same event, two destinations, two rows. Nothing was duplicated: your bucket has
more than one forwarder, and each one delivers independently, [with its own
queue, clock and breaker](/blog/fan-out-without-three-failure-domains).

This one is worth catching early because the "duplicate" is usually a design
problem in disguise. If two of your services both write to the same table on
the same event, the delivery layer is doing exactly what you configured, and
the thing to change is which service owns that write.

### 3. A replay somebody started

Including you, last Tuesday, on a filtered set that was wider than you thought.

```text
forward_type:REPLAY
```

Every delivery is tagged `ATTEMPT` or `REPLAY`, and a replayed one also carries
its `replay_job_id`, so a mystery duplicate resolves to a specific job somebody
ran ([replay](/docs/history/replay)). This is the case people forget exists,
and it is the easiest of the four to confirm or rule out.

### 4. Two genuinely different events

The provider sent two things. They describe one fact in your business, so they
look like duplicates to you, but they are unrelated at the transport layer.
Payment providers do this routinely, emitting both a charge-level and an
intent-level event for one payment.

Nothing in the delivery pipeline caused this and nothing in it can fix it. Your
deduplication key is scoped to the wrong thing.

## The case nobody can resolve for you, including us

There is a fifth situation, and it is the one worth being straight about.

When a provider does not see your acknowledgement, it sends the request again.
Repost captures inbound requests as they arrive, so those two requests are two
events, with two different event ids. From the receiving side they are
genuinely two events. Nothing in the HTTP exchange says otherwise.

The only thing that links them is the identifier the provider put inside the
payload. Which means the query that answers "did they send this twice" is not
about our identifier at all:

```text
request.body_json.id:"evt_1PabcXYZ"
```

Any JSON field in the body is searchable with dot notation, so this works for
whatever your provider calls its id ([the full field
list](/docs/history/search)).

> **Which id belongs in your dedupe table**: The provider's id, not ours. An event id from your webhook infrastructure identifies a request that was received. The provider's identifies the thing that happened. When you are the one sending, this is the whole reason [the signed id stays constant across retries](/docs/send/signing): a consumer cannot deduplicate on something that changes every attempt.

One thing does get smaller here. A queued bucket answers the provider on
arrival rather than after your service has finished, so the window where an
acknowledgement can go missing no longer contains your application's
processing time. Smaller is not zero. A network can still drop the reply.

## What you actually change

| What you found | Where the fix lives |
|---|---|
| `attempt` above 1 | Your handler, which has to tolerate seeing the event twice |
| Two forwarders | Your topology: decide which destination owns the write |
| `forward_type:REPLAY` | Nothing. Narrow the filter next time |
| Two event ids, one payment | Your dedupe key, which is scoped to the wrong object |
| Two event ids, same provider id | Your dedupe key again, this time keyed on the provider's id |

The provider you came here to blame is not in that table. Four of the five rows
are your code, which sounds harsh and is actually the good news: they are the
ones you can fix today, without waiting on anybody's support queue.

## What this does not solve

**Ordering.** Two deliveries of one event tell you nothing about the order of
two different events, and no provider promises you one. If your state machine
depends on sequence, it has to read that sequence out of the payload.

**Providers that ship no stable id.** Some send you a body and nothing to hold
on to. You are then building a key out of business fields, a customer plus an
amount plus a rounded timestamp, and that key is a guess with a failure mode:
too narrow and you process a real second payment twice, too wide and you drop
one your customer actually made. Pick the direction you would rather be wrong
in, and write it down next to the code, because the next person will not guess
which way you leaned.

**See it on your own events.** Open a live inbox and inspect your first webhook in seconds (50K events/month free).
