← Webhook errors

Duplicate webhook

The same business event showed up more than once. Mercur does not retry or queue; extras come from the provider, a dashboard resend, or Retry in the console.

What this symptom means

You processed (or logged) the same business event more than once: two Stripe evt_… deliveries, two GitHub X-GitHub-Delivery ids for one push, two Telegram updates with the same update_id, or one provider delivery plus a Mercur Retry.

Mercur is not a webhook gateway. It does not retry, buffer while the agent is down, or dedupe by event id. Each POST the provider (or Retry) sends is a separate log row.

Guest inspector always returns 204. The provider treats that as success and will not retry. Duplicates you see there are usually two test clicks, two subscribed endpoints, or GitHub ping + push — not Mercur replaying on its own.

Common causes

  • Provider retry after a non-2xx or timeout — same payload, new delivery attempt
  • Two webhook endpoints configured (Dashboard + CLI listen, or two Mercur URLs)
  • GitHub ping plus the real event; Stripe test webhook plus a live event with similar type
  • At-least-once delivery: the provider got a 2xx late, or never, and sent again
  • You clicked Redeliver / Resend in the provider UI
  • Someone used Retry in Mercur console — that is a new POST through the public URL on purpose
  • Handler not idempotent: two different X-GitHub-Delivery values still share one after commit SHA

Verify the incoming request

In Mercur, open consecutive rows and compare:

  • Timestamp and source IP
  • Provider delivery id header (Stripe-Signature is not an id; use body id. GitHub: X-GitHub-Delivery. Telegram: update_id)
  • Body equality vs same business key (data.object.id, commit SHA)

Two rows with different delivery ids are two provider attempts, even if JSON looks identical. Two rows with the same stored payload after you pressed Retry are expected.

Provider and framework notes

Stripe: retries use the same Event id. Idempotency key should be event.id (or data.object.id plus type), not “first webhook this minute.”

GitHub: retries get a new X-GitHub-Delivery. Dedup on that header, or on a hash of delivery id. GitHub may deliver events out of chronological order.

Telegram: retries and overlapping setWebhook can repeat update_id. Store processed update_id values.

Do not return 500 “so they will retry later” unless you can handle the duplicate; that is how duplicate storms start.

Debugging checklist

  1. Count Mercur rows for the window; each row is one HTTP POST.
  2. Read delivery ids; decide provider retry vs two subscriptions vs Retry.
  3. Make the handler idempotent on the provider’s event id.
  4. Return 2xx once you have persisted that id, even if later work is async.
  5. Stop using Retry as a load test against a non-idempotent handler.

Confirm with the webhook inspector

Fire one provider test. Guest: one row, 204, no retries from Mercur, idle TTL 1 day, cap 256 KB. If two rows appear, the provider sent twice (two endpoints or two events).

Inspect the request Connect localhost

Connect localhost

Duplicates that hit business logic only happen when the handler runs. Connect the agent, keep one provider URL, and persist event ids before side effects.

Forwarding needs a connected agent — the macOS app or the npm package @mercur_dev/cli.

Disconnected agents do not queue; you will not get a burst of “missed” webhooks from Mercur when you reconnect. You may get provider retries if they saw 503 while you were down.

Replay the same event

Retry is a manual extra delivery. It helps after a fix; it will look like a duplicate if the handler already succeeded. Use it when you want the same bytes again, then rely on idempotency.

Requires proxy mode, connected agent, complete payload. Not available on the guest inspector. Mercur will not auto-replay failed rows.

Related