TILLAUTH · OPERATE

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.