Webhooks
Tell your own services when something happens in the IdP — a registration, a role change, a suspension, a deletion — instead of polling for it.
NUXT_WEBHOOKS_JSON='[{"id":"crm","target":"https://crm.example.com/hooks/idp","events":["register","account.deleted"],"secret":"…32+ random characters…"}]'That is the whole configuration. One array, one entry per receiver, reload on restart like every other setting.
The events are the audit log's
A subscription does not name invented webhook events; it names the audit events this instance already records, which is why the list needs no separate maintenance and why anything you can see in Admin → Audit logs you can also be notified about.
| Pattern | Matches |
|---|---|
register | exactly that event |
admin.user.* | every event under that prefix — admin.user.suspended, admin.user.roles_updated, … but not admin.force_logout_all |
* | everything |
A name that matches nothing fails the start, naming the subscription: a typo'd event that silently never fires is the one failure mode a webhook feature must not have.
The full vocabulary is in the Configurator's webhook editor, grouped by prefix (login.*, password.*, admin.*, totp.*, provisioning.*, …) — the same names the audit log shows.
What arrives
POST to your target, Content-Type: application/json:
{
"id": "c48c8b6f-2c45-4334-a53e-2abc79741ca8",
"event": "register",
"status": "success",
"timestamp": "2026-09-24T17:04:07.392Z",
"userId": "38df74ae-84b2-4e2c-807f-f914956db8dd",
"email": "new.user@example.com",
"targetUserId": null,
"reason": null
}userId is the actor; targetUserId is the account an admin action was about, when that differs. status is success or failure — a login.failed delivery is a failed login, not a failed webhook.
Headers:
| Header | |
|---|---|
X-Schleuse-Event | the event name |
X-Schleuse-Delivery | a UUID, the same value as id in the body — retries of one delivery share it |
X-Schleuse-Id | your subscription's id |
X-Schleuse-Signature | present only when the subscription has a secret |
Verifying the signature
With a secret, every delivery carries
X-Schleuse-Signature: t=1758733447,v1=9f86d0818940…v1 is HMAC-SHA256(secret, "<t>.<raw body>"), hex. Check it against the raw body, before parsing:
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verify(rawBody, header, secret) {
const t = /t=(\d+)/.exec(header)?.[1]
const v1 = /v1=([a-f0-9]+)/.exec(header)?.[1]
if (!t || !v1) return false
// Reject anything older than five minutes: the timestamp is what makes a
// captured delivery un-replayable.
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
const a = Buffer.from(expected), b = Buffer.from(v1)
return a.length === b.length && timingSafeEqual(a, b)
}A subscription without a secret is unauthenticated
Anyone who learns your endpoint can post a convincing "account deleted" to it. Set a secret — 16 characters minimum, and the Configurator generates one.
Delivery is best-effort
One attempt, plus the retries you configure:
NUXT_WEBHOOK_RETRY_DELAYS_MS=1000,5000,30000 # empty = single attemptRetries live in memory and are lost on restart. There is no delivery queue, no dead-letter store and no replay endpoint — deliberately: they would need a database Schleuse does not want to grow. If you need guaranteed delivery, put a queue behind your endpoint; that is where it belongs.
What you get instead:
- a
warnline naming the subscriptionidwhen a delivery gives up (never the target URL, which may carry a token in its path), schleuse_webhook_deliveries_total{id,result}on/metrics,- and the test button below.
A slow receiver cannot slow the IdP down. Deliveries are never awaited by the request that triggered them — measured: a receiver that takes three seconds leaves a sign-up at 0.06 s.
Testing an endpoint
Admin → Configurator → the webhook's Send test delivers a signed webhook.test event using the configuration the instance is running (not what is typed in the form) and reports what came back: status, duration and the start of the receiver's body. 403 · 12 ms · bad signature is a far shorter path to a working integration than deleting a real account and waiting.
The test writes an admin.webhook.test_sent audit row, and that one event is deliberately never delivered — otherwise a subscription to * would receive an echo of its own test.
Security notes
- Payloads carry an email address. Treat a webhook endpoint as a processor of personal data, and prefer HTTPS targets.
- A subscription to
login.failedon a public instance turns a brute-force attempt into outbound traffic at the same rate. Subscribe deliberately. - Secrets are masked wherever the admin panel shows configuration — in the Configurator and in every export format.
Related
- Configuration — the two variables
- Admin panel — the editor and the test button
- Security — what else leaves this instance