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 delivery log you can replay from.
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 store it encrypted at rest. The dashboard then 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
User-Agent: tillauth-webhooks/1
X-TillAuth-Event: signin.password.ok
X-TillAuth-Delivery: 7c0e… (delivery id; a replay gets a new one)
X-TillAuth-Signature: t=1717228800,v1=<hex>
{
"type": "signin.password.ok",
"result": "ok",
"ts": "2026-06-01T10:00:00.000Z",
"app_id": "…",
"user_id": "…",
"ip": "…",
"user_agent": "…",
"metadata": { … },
"delivery_id": "7c0e…",
"attempt": 1
}Verifying the signature
verifyWebhook from @tillstack/auth-node checks the signature, rejects a timestamp more than five minutes old, and returns the parsed event. It uses Web Crypto, so it runs on Node, Workers, Deno and Bun.
import express from 'express'
import { verifyWebhook, TillAuthWebhookError } from '@tillstack/auth-node'
const app = express()
// express.raw keeps the exact bytes TillAuth signed.
app.post('/webhooks/tillauth', express.raw({ type: 'application/json' }), async (req, res) => {
try {
const event = await verifyWebhook(req.body, req.get('x-tillauth-signature'), process.env.TILLAUTH_WEBHOOK_SECRET!)
await handle(event.type, event.user_id, event.delivery_id)
res.sendStatus(204)
} catch (e) {
if (e instanceof TillAuthWebhookError) return res.status(400).send(e.reason)
throw e
}
})
declare function handle(type: string, userId: string | null, deliveryId: string): Promise<void>Without the SDK: split X-TillAuth-Signature into t and v1, compute hex(hmac_sha256(secret, t + "." + raw_body)), compare it to v1 in constant time, and reject if t is more than 300 seconds from now. The secret is the full whsec_… string. Hash the raw request bytes, never a re-serialized object.
Retries
A non-2xx response, or no response within 10 seconds, schedules a retry. Six attempts in total, about 12½ hours end to end:
- Attempt 1: immediate
- Attempt 2: +1 min
- Attempt 3: +5 min
- Attempt 4: +25 min
- Attempt 5: +2 hr
- Attempt 6: +10 hr, final.
The body’s attempt field says which one you’re seeing; delivery_id stays the same across retries, so use it to deduplicate. After the last attempt the delivery is marked failed and the endpoint shows its last HTTP status.
Replay
Every delivered or failed row under Recent deliveries has a Replay button (owner or admin). It sends the same event as a new delivery with a fresh id and timestamp, and adds replay_of with the original delivery id. Scripted: POST /api/auth/admin/apps/:appId/webhooks/:endpointId/deliveries/:deliveryId/replay with an API key holding auth.apps.write.
Event types
The event type is the audit action that caused it (see the audit-log vocabulary). Only these actions send webhooks; the dashboard lists all of them, and the API refuses any other name. Common ones:
signup.password.ok· new password user.signin.password.ok· primary signin succeeded.signin.passkey.ok· passkey signin succeeded.signin.oauth.ok· social / SSO signin.signin.push.ok· approved from the TillAuth authenticator.signin.email_code.ok· an unusual sign-in finished with the code we emailed.signin.risk/signin.risk_step/signin.risk_blocked· a sign-in scored 30 or more, owed an extra step, or was blocked by the session-risk policy.sso.session.used/sso.session.refused/sso.session.ended· a browser was signed in by its SSO session, was sent to a full sign-in by the session-risk policy, or signed out everywhere.mfa.totp.enrolled/mfa.totp.disabled.password.changed· user changed their own password.password.reset.ok· password reset completed.session.revoked· a session was signed out or revoked.session.risk/session.risk_reauth/session.risk_revoked· a refresh scored 30 or more, or the session-risk policy ended sessions.device.new· first signin from a new device fingerprint.access.signed_in/access.signed_out· someone signed in to, or out of, one of your internal apps.
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 deliveries are not retroactively re-signed.
Next: audit log for the source of truth.