Webhooks
Usher can tell your servers what happens in an environment as it happens: someone joins, signs in, changes their details or leaves. Each event is a signed POST to an address you choose, tried again if your server doesn’t answer.
Add an endpoint
In Usher Console, open the environment, then Webhooks → New endpoint. Give an https:// address of yours and choose the events it should hear about. Console shows the endpoint’s signing secret (whsec_…) once: keep it with your server’s other secrets.
| Event | When |
|---|---|
user.created | Someone made an account |
user.updated | An account changed: email, name, verification or ban (including a ban by your organisation’s directory, over SCIM) |
user.deleted | An account was deleted |
session.created | Someone signed in |
session.ended | A session ended: signed out, revoked or expired early |
Send a test sends a webhook.test event to that endpoint alone, whatever it is subscribed to.
What arrives
POST /usher/webhooks HTTP/1.1
content-type: application/json
user-agent: Usher-Webhooks/1
webhook-id: msg_2fk7x…
webhook-timestamp: 1793643116
webhook-signature: v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE={
"type": "user.created",
"timestamp": "2026-11-02T18:11:56.412Z",
"data": {
"user": {
"id": "Qz3m…",
"email": "lena@acme.com",
"emailVerified": false,
"name": "Lena Live",
"image": null,
"banned": false,
"createdAt": "2026-11-02T18:11:56.380Z",
"updatedAt": "2026-11-02T18:11:56.380Z"
}
}
}Events describe people and sessions, never secrets: no tokens, no password hashes, no roles, and not why someone was banned.
Check every delivery
Usher signs to the Standard Webhooks specification, so any of its libraries checks the signature for you. Refuse anything that doesn’t verify. The libraries also refuse a timestamp more than five minutes away, so an old delivery can’t be replayed.
// a route that receives Usher's webhooks (Node, the standardwebhooks package)
import { Webhook } from 'standardwebhooks'
const wh = new Webhook(process.env.USHER_WEBHOOK_SECRET!) // whsec_…, from Usher Console
export async function POST(request: Request) {
const body = await request.text() // the raw body: the signature covers it byte for byte
let event: { type: string; timestamp: string; data: Record<string, unknown> }
try {
event = wh.verify(body, Object.fromEntries(request.headers)) as typeof event
} catch {
return new Response('bad signature', { status: 400 })
}
// webhook-id is the same on every attempt: skip one you have handled already
if (await alreadyHandled(request.headers.get('webhook-id')!)) return new Response(null, { status: 204 })
await queue(event) // answer quickly; do the work afterwards
return new Response(null, { status: 204 })
}- Answer within 15 seconds with any
2xx. Anything else, a redirect included, counts as a failure. - Expect repeats. A message keeps its
webhook-idon every attempt; skip one you have handled. - Don’t rely on order. Retries can arrive after newer events.
When your server doesn’t answer
Usher tries again 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours later (about a day and a half in all), then gives up. Console’s delivery log shows every message’s attempts and your server’s last answer, and Send again starts a delivery over.
A new secret
New secret in Console makes another one and shows it once. For the next 24 hours each delivery carries two signatures, one per secret, so you can deploy the new secret without missing anything. After that only the new one signs.
Where deliveries come from
Webhooks leave Usher through its egress proxy, which won’t connect to private or internal addresses. An endpoint must be an https:// address on a public name: not an IP address, a private name, or an address on Usher’s own domains.