---
title: "Webhooks that arrive, and what to do when they arrive twice"
summary: "Twelve events, a signature over timestamp and body, eight attempts over about 21 hours, and one id per event so duplicates are cheap to drop."
author: "Samuel Krauss"
author_title: "Founder"
publisher: "Elchi Studios"
published: 2026-10-03
updated: 2026-10-03
url: https://elchi.dev/en/journal/webhooks-that-arrive
language: en
tags: ["eauth","webhooks","integration"]
words: 1096
---

# Webhooks that arrive, and what to do when they arrive twice

*By Samuel Krauss, Founder. Published 3 October 2026.*

> Twelve events, a signature over timestamp and body, eight attempts over about 21 hours, and one id per event so duplicates are cheap to drop.

A sign-in provider that only answers questions is half a provider. Your system needs to hear when a person registers, when they sign in, when they revoke your application's access or when the account is deleted, without polling for it. EAuth tells you through webhooks, and this post is about the parts that decide whether a webhook integration holds up in production: what is sent, how you know it is ours, what happens when your server is down, and what to do with a delivery you have already seen.

## What is sent

Twelve events. Seven are about people and their sessions: `user.created` when somebody registers or single sign-on creates an account; `user.signed_in` on every authorization code you exchange, not on refreshes; `consent.granted` and `consent.revoked`; `session.revoked` with a reason, whenever your refresh tokens for somebody stopped working without you asking, which covers signing out everywhere, a password reset, a token used twice and a person leaving an organisation; `user.password_changed`; and `user.deleted`. Five are about organisations: one created, one deleted, and a member added, updated or removed. An endpoint subscribed to nothing receives all of them.

Each body is a small JSON object: the event's id and type, when it happened, your client id, and a `data` object with ids and little else. If you need the person's current name or address, you ask the API with the id. A webhook that carried the profile would be a copy that goes stale the moment it is written.

Since 3 October one rule shows in the order of events: no application gets an account whose email address is not confirmed. `user.created` still arrives the moment somebody registers, with `email_verified` false, but your application gets no token for them until they have opened the link. So `user.signed_in` comes after the confirmation, and somebody who never confirms leaves you a `user.created` and nothing else. An account made by single sign-on arrives with `email_verified` true.

## How you know it is ours

Every delivery carries `Elchi-Signature: t=<timestamp>,v1=<hmac>`, where the HMAC-SHA256 is computed with your endpoint's secret over the timestamp, a dot and the raw body. The scheme is Stripe's with the header renamed, so an existing verifier needs one line changed. Check it against the raw bytes you received, before you parse anything, with a constant-time comparison, and refuse a timestamp more than five minutes from your own clock. The timestamp inside the signed string is what makes a captured delivery useless later: the body of a `user.deleted` event never changes, so without the timestamp a replay would be indistinguishable from the original.

The secret starts with `whsec_`, so a leaked one is recognisable in a log or a repository scan, and it is shown once, when the endpoint is made. On our side it cannot be a digest, since it has to sign every delivery, so it is stored encrypted with AES-256-GCM under a key that never touches the database. The export of your application leaves it out. There is no rotation button: to change the secret, add a second endpoint with the same URL, accept either secret until the new one delivers, then delete the old one. During that overlap every event arrives twice with the same id, which the section after next makes harmless.

## What happens when your server is down

A delivery that does not get a 2xx answer within ten seconds has failed. Each event gets eight attempts: the first at once, then after 30 seconds, 2, 10 and 30 minutes, and 2, 6 and 12 hours, each wait counted from the attempt before. The last attempt comes about 21 hours after the first, and a delivery that fails then is marked abandoned.

The endpoint as a whole has a limit too. Once 25 attempts in a row have failed, counted across all its events, it is turned off, and the console marks it disabled. No mail is sent. While it is off, nothing is queued for it: events that happen in that time are never delivered. Deliveries that were already waiting resume when you turn the endpoint back on in the console, and an abandoned one can be sent again by hand.

Read together, that is the trade-off. A quiet application whose receiver is down for ten minutes loses nothing: a handful of events, a few retries each. A busy one reaches 25 failed attempts within minutes, and from then until somebody turns the endpoint back on, its events are gone. After an outage of your receiver, look at the console, not only at your own logs.

Ten seconds is not long, on purpose. Answer 200 the moment you have stored the body and do the work afterwards. A handler that calls three other services before it answers is retried like a failed one, and then it runs four times.

## What to do with a delivery you have seen

Every attempt of an event carries the same `Elchi-Event-Id`, which is also the `id` in the body. Store it with the work you did, and when an id arrives that you already have, answer 200 and discard the body without parsing it. That single rule turns at-least-once delivery, which is what any webhook system honestly offers, into exactly-once processing on your side.

It also makes the replay button safe to press. The console lists each endpoint's eight most recent deliveries with their status, response code, attempts and time, and any of them that was not delivered can be sent again. Deliveries are kept for 30 days and then deleted with their payloads, since a payload names a person.

## Rules for the URL

`https` only, with a host and no credentials in it. `localhost` and literal private or reserved addresses are refused when the endpoint is registered. A hostname is checked when it is used: every address it resolves to is checked again at each connection, so a name that later points inside a network is still refused. That is the check that stops a webhook system from being used to reach a server it should never see. Redirects are not followed, since following one could turn a signed POST into a request to somewhere you never registered.

## What is not there

There is no batching and no ordering guarantee across events: two events for one person may arrive out of order when one is retried, so handle each on its own facts, `session.revoked` included. There is no event for an import, since your system already knows those people. There is no queue for the time an endpoint is turned off, and no mail when it happens. And the console shows the latest eight deliveries per endpoint, not a searchable log of the 30 days.

## Sources

1. [Webhooks, EAuth documentation](https://docs.elchi.dev/webhooks), Elchi Studios, read 2026-10-03
2. [RFC 2104: HMAC: Keyed-Hashing for Message Authentication](https://www.rfc-editor.org/rfc/rfc2104.html), IETF, read 2026-10-03
3. [Resolve webhook signature verification errors](https://docs.stripe.com/webhooks/signature), Stripe, read 2026-10-03
4. [Server-Side Request Forgery Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html), OWASP, read 2026-10-03
