Webhooks — signed, retried, replayable.
When something happens in TillAuth — a signin, a signup, an MFA enrollment, a session revoke — you want to know. HMAC-SHA256 signed deliveries with exponential-backoff retries and a permanent delivery log.
Registering an endpoint
From the per-app Webhooks tab, paste a URL and pick the events you care about. On save we generate a 32-byte signing secret, return it once (the only chance to copy it), and persisttillauth_webhook_endpoints.secret_enc — app-DEK-encrypted. The display row shows only the secret prefix (whsec_a1b2…).
The events array can either be empty (subscribe to everything) or contain specific event types — for example["signin.password.ok", "session.theft_detected"].
Request shape
POST /your/webhook/path HTTP/1.1
Content-Type: application/json
TillAuth-Event: signin.password.ok
TillAuth-Delivery: d_01h6q7… (delivery row id)
TillAuth-Timestamp: 1717228800 (unix seconds)
TillAuth-Signature: t=1717228800,v1=<hex>
{
"id": "evt_…",
"type": "signin.password.ok",
"app_id": "…",
"user_id": "…",
"occurred_at": "2026-06-01T10:00:00Z",
"data": { … }
}Verifying the signature
Computehmac_sha256(secret, "<timestamp>.<raw_body>") and compare hex-encoded to the v1= value in TillAuth-Signature. Constant-time compare. Reject if the timestamp is more than 5 minutes old — defeats replays.
Node example:
import { createHmac, timingSafeEqual } from 'node:crypto'
function verify(req, secret) {
const sig = req.headers['tillauth-signature']
const ts = req.headers['tillauth-timestamp']
if (!sig || !ts) return false
if (Math.abs(Date.now()/1000 - Number(ts)) > 300) return false // 5-min window
const expected = createHmac('sha256', secret)
.update(`${ts}.${req.rawBody}`).digest('hex')
const got = (sig.match(/v1=([0-9a-f]+)/)?.[1]) ?? ''
return got.length === expected.length &&
timingSafeEqual(Buffer.from(got, 'hex'), Buffer.from(expected, 'hex'))
}Use the raw request body, not a re-serialized JSON object — the bytes we hashed are the bytes you got.
Retries
Non-2xx responses (or timeouts after 10 seconds) flip the delivery row to in_flight → failed and schedule a retry:
- Attempt 1: immediate
- Attempt 2: +1 min
- Attempt 3: +5 min
- Attempt 4: +30 min
- Attempt 5: +2 hr
- Attempt 6: +12 hr — final.
After the final retry the row stays at failed and the endpoint's last_status column is the last HTTP code (or null on timeout). The dashboard surfaces any endpoint with a non-2xx last_status as an attention badge.
Replay
Every delivery — pending, delivered, failed — keeps its row in tillauth_webhook_deliveries for the same retention as the audit log (365 days). From the per-app or org webhook dashboard you can replay a delivery: we re-sign with the current timestamp and dispatch again.
Event types
The event-type string is the same dotted-namespace token as the audit action — see the audit-log vocabulary. A few of the common ones:
signup.password.ok· new password user.signin.password.ok· primary signin succeeded.signin.passkey.ok· passkey signin succeeded.signin.oauth.ok/signin.oidc.ok· social / SSO signin.mfa.totp.enrolled/mfa.totp.disabled.password.changed· user changed their own password.password.reset.ok· password reset completed.session.theft_detected· reused refresh token; whole family killed.device.new· first signin from a new device fingerprint.
Failure modes you'll actually see
- SSL handshake failed — your endpoint's cert expired. Audit logs the underlying error verbatim.
- Timeout — endpoint took more than 10s. Reduce work inline; persist + ack first.
- Signature mismatch on your side — almost always rebuild-the-body issues. Use the raw request bytes.
- Wrong secret — rotate from the per-app webhooks tab; old delivery rows are not retroactively re-signed.
Next: audit log for the source of truth ·DEK rotation for crypto key hygiene.