Webhooks
Your application is told when something happens, instead of asking.
Events
| Type | When | data |
|---|---|---|
user.created |
Somebody registered an account at your application, or single sign-on created one. Not for accounts you import. | user_id, email, email_verified |
user.signed_in |
Somebody signed in to your application: every authorization code you exchange. Not on refresh. | user_id, scope |
consent.granted |
Somebody agreed to the scopes you asked for. | user_id, scopes |
consent.revoked |
Somebody disconnected your application from their account page. | user_id |
session.revoked |
Your refresh tokens for somebody stopped working without you asking: they signed out everywhere, reset their password, a token was used twice, they are no longer in the organisation the tokens were for, their organisation now allows single sign-on only, or single sign-on took over an account nobody had confirmed. | user_id, reason, and organisation_id when the tokens were for one organisation |
user.password_changed |
Relevant if you cache anything derived from the old one. | user_id, how |
user.deleted |
The account was deleted in the console. Delete what you keep about the person. | user_id |
organisation.created |
An organisation was created in the console. | organisation_id, slug, name |
organisation.deleted |
An organisation was deleted. | organisation_id, slug |
organisation.member_added |
Somebody joined an organisation: by invitation, added in the console, by domain, or at single sign-on. | organisation_id, user_id, role, via |
organisation.member_updated |
A member's role or status changed. | organisation_id, user_id, role, status |
organisation.member_removed |
A member was removed from an organisation. | organisation_id, user_id |
An endpoint subscribed to no events receives all of them.
What a delivery looks like
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: EAuth-Webhooks/1.0
Elchi-Event-Id: 01a06c8a-…
Elchi-Signature: t=1788000000,v1=5f3a…
{
"id": "01a06c8a-…",
"type": "consent.revoked",
"created_at": "2026-09-04T10:12:00Z",
"client_id": "eauth_cnf_…",
"data": { "user_id": "…" }
}
Verifying the signature
The conventions are Stripe's, so an existing verifier will work with the
header name changed. The signature is HMAC-SHA256 over timestamp + "." + body
using your endpoint's secret.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, header, rawBody, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (age > toleranceSeconds) throw new Error("stale");
const expected = createHmac("sha256", secret)
.update(`${parts.t}.`).update(rawBody).digest("hex");
if (!timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))) {
throw new Error("bad signature");
}
}
Verify against the raw request body, before any JSON parsing. A parser that reorders keys or normalises whitespace changes the bytes and the signature stops matching.
The timestamp is part of what is signed, so a captured delivery cannot be replayed later even though nothing about it changed. Reject anything older than five minutes.
Retries
A delivery that does not get a 2xx response within ten seconds is tried again. Each event gets eight attempts in all: 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 comes about 21 hours after the first; a delivery that fails then is marked abandoned.
Every attempt carries the same Elchi-Event-Id. If you see one twice, you
already handled it; discard the second without parsing.
After 25 failed attempts in a row, counted across all its events, the endpoint is disabled and shown as such in the console, where it can be re-enabled once fixed. No mail is sent. While it is disabled, events are not queued for it: what happens in that time is not delivered. A quiet application whose receiver is down for ten minutes loses nothing; a busy one reaches 25 failures sooner, so check the console after an outage of your receiver. The console lists each endpoint's eight most recent deliveries, and any that was not delivered can be sent again.
Rules for the URL
https only, a public hostname, no credentials in it. Private and reserved
addresses are refused at registration and again at connection time, so a
hostname that later resolves to something internal is still refused.
Redirects are not followed.
Respond fast and do the work afterwards. The request times out after ten seconds, and a slow handler is retried like a failed one.