<!-- https://docs.elchi.dev/webhooks | EAuth | Elchi Docs -->

# 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

```http
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.

```js
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.
