Webhooks
Your application is told when something happens, instead of asking.
Events
| Type | When |
|---|---|
user.signed_in |
A session was established for your application. Not on refresh. |
consent.granted |
Somebody agreed to the scopes you asked for. |
consent.revoked |
Somebody disconnected your application from their account page. |
session.revoked |
A session was ended, by the user or by us. |
user.password_changed |
Relevant if you cache anything derived from the old one. |
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 is retried eight times over roughly a day: after 30 seconds, then 2, 10 and 30 minutes, then 2, 6 and 12 hours. A receiver down for ten minutes loses nothing.
Every attempt carries the same Elchi-Event-Id. If you see one twice, you
already handled it; discard the second without parsing.
After 25 consecutive failures the endpoint is disabled and shown as such in the console, where it can be re-enabled once fixed. Individual deliveries can be replayed from there too.
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.