Skip to content

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.

bash
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.

PatternMatches
registerexactly 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:

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-Eventthe event name
X-Schleuse-Deliverya UUID, the same value as id in the body — retries of one delivery share it
X-Schleuse-Idyour subscription's id
X-Schleuse-Signaturepresent 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:

js
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:

bash
NUXT_WEBHOOK_RETRY_DELAYS_MS=1000,5000,30000   # empty = single attempt

Retries 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 warn line naming the subscription id when 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.failed on 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.

A small but polished sidecar identity provider. · llms.txt