Arrow keys to move, Enter to open, Escape to close.

  1. Docs
  2. EAuth
  3. Webhooks

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.

4 min read