Auth guides
08 · Webhooks + audit

Hear about lifecycle events. Read the audit log.

Two trailing-edge surfaces: webhooks deliver real-time events to your backend (signup, verification, role change, tenant added); the audit log is the durable record of every mutation, queryable for support and compliance.


1

What you can subscribe to

Events Auth fires:

Event types
# EndUser lifecycle
user.signup
user.signin
user.contact_verified             # any email/phone contact verified
user.email_verified               # alias, fired when it's the primary email
user.password_reset.requested
user.password_reset.completed
user.suspended
user.deactivated
user.reinstated
user.role_changed
user.deleted

# Tenants
tenant.created
tenant.updated
tenant.deleted
tenant.member.added
tenant.member.role_changed
tenant.member.removed

# MFA
user.mfa.factor_enrolled
user.mfa.factor_disabled
user.mfa.recovery_code_used
user.mfa.step_up_succeeded
user.mfa.step_up_locked

# App lifecycle
app.slug_changed                  # your JWKS URL is moving — 30-day alias

Webhooks are per-app — each app has one webhook URL + one signing secret. If you run several apps, configure one per app.


2

Configure your webhook

From the console (Auth → your app → Integration → Webhooks → Configure webhook) or via the auth-admin API. The URL must be public https://.

PUT /v1/apps/:appId/webhook
curl -X PUT https://api.auth.productcraft.co/v1/apps/<appId>/webhook \
  -H 'authorization: Bearer pcft_live_...' \
  -H 'content-type: application/json' \
  -d '{
    "url":         "https://acme.example.com/webhooks/auth",
    "event_types": ["user.signup", "user.email_verified", "user.role_changed"]
  }'
{
  "id":                  "41dab21f-...",
  "app_id":              "53641fdb-...",
  "url":                 "https://acme.example.com/webhooks/auth",
  "event_types":         ["user.signup", "user.email_verified", "user.role_changed"],
  "signing_secret":      "BDGVRE...",   # returned exactly ONCE
  "signing_secret_hint": "flao",        # last 4 chars, for later identification
  "disabled_at":         null,
  "disabled_reason":     null,
  "consecutive_failures": 0,
  "created_at":          "2026-05-11T...",
  "updated_at":          "2026-05-11T..."
}

signing_secret is returned exactly once (reads via GET .../webhook only show the last-4 hint). Store it in your secret manager — you'll use it to verify signatures on inbound webhook calls.


3

Payload shape

Every event arrives as a JSON POST to your URL with an X-Auth-Signature header.

POST https://acme.example.com/webhooks/auth
content-type: application/json
x-auth-event-type: user.email_verified
x-auth-event-id:   evt_fb580f79-...
x-auth-signature:  t=1778499378,v1=...hex...

{
  "id":         "evt_fb580f79-...",
  "type":       "user.email_verified",
  "created_at": "2026-05-11T11:38:42Z",
  "app_id":     "53641fdb-...",
  "data":       { ...event-specific fields... }
}

4

Verify the signature

HMAC-SHA256 of the raw body, keyed by your webhook secret. Constant-time compare. Reject anything older than five minutes to prevent replay.

Express/Node
import crypto from 'node:crypto';

const WEBHOOK_SECRET = process.env.AUTH_WEBHOOK_SECRET!;
const MAX_AGE_S = 300;

app.post('/webhooks/auth', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.header('x-auth-signature') ?? '';
  const m = /t=(\d+),v1=([0-9a-f]+)/i.exec(sig);
  if (!m) return res.status(400).send('malformed signature');

  const ts  = Number(m[1]);                 // timestamp comes from the header itself
  const age = Math.floor(Date.now() / 1000) - ts;
  if (Math.abs(age) > MAX_AGE_S) {
    return res.status(400).send('stale signature');
  }

  const payload = req.body as Buffer;
  const expected = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(`${ts}.`)
    .update(payload)
    .digest('hex');

  const provided = Buffer.from(m[2], 'hex');
  const expectedB = Buffer.from(expected, 'hex');
  if (provided.length !== expectedB.length ||
      !crypto.timingSafeEqual(provided, expectedB)) {
    return res.status(400).send('signature mismatch');
  }

  // Signature good. Parse and act.
  const event = JSON.parse(payload.toString('utf8'));
  // ... do work, then ...
  res.status(204).send();
});

Always use the raw body for HMAC computation. Body-parser middleware that re-serialises the JSON will produce different bytes than what Auth signed.


5

Delivery + failure policy

Auth expects a 2xx within 15 seconds. Anything else is recorded as a failed attempt.

  • One delivery attempt per event today. Automatic retries are not live yet — a failed delivery is recorded on the attempts log (next section) and the event is not re-sent. Poll the attempts log if your endpoint had an outage.
  • Auto-disable. After 20 consecutive failures, or 24 hours of uninterrupted failures, the webhook is auto-disabled — disabled_at and disabled_reason are set on the config and no more events fire. Re-enable by re-upserting the config (PUT .../webhook clears the disabled state). The console mirrors this on Integration → Webhooks: an Auto-disabled badge next to the destination, plus a Health block showing Consecutive failures, the disabled timestamp and reason, and a “Re-save to re-enable” hint.
  • Be idempotent anyway. Use the x-auth-event-id header (also id in the body) as your idempotency key so duplicate deliveries stay safe when retries ship.

6

Inspect delivery attempts

Every attempt is recorded with status code, latency, and error. Returns a plain array, newest first, capped by ?limit= (no cursor). The console renders the same data as a 'Recent delivery attempts' table (last 50) under Integration → Webhooks.

GET /v1/apps/:appId/webhook/attempts
curl https://api.auth.productcraft.co/v1/apps/<appId>/webhook/attempts?limit=20 \
  -H 'authorization: Bearer pcft_live_...'
[
  {
    "id":             "45cc11a6-...",
    "event_id":       "evt_fb580f79-...",
    "event_type":     "user.email_verified",
    "attempt_number": 1,
    "status_code":    204,
    "error_message":  null,
    "latency_ms":     148,
    "attempted_at":   "2026-05-11T11:38:43Z",
    "url":            "https://acme.example.com/webhooks/auth"
  },
  {
    "id":             "8c1d22f0-...",
    "event_id":       "evt_a91b64c2-...",
    "event_type":     "user.signin",
    "attempt_number": 1,
    "status_code":    null,
    "error_message":  "request timed out",
    "latency_ms":     15003,
    "attempted_at":   "2026-05-11T11:39:02Z",
    "url":            "https://acme.example.com/webhooks/auth"
  }
]

7

Rotate the webhook secret

When you suspect compromise, or on a periodic schedule. Same pattern as M2M rotation — old secret stops working immediately.

POST /v1/apps/:appId/webhook/rotate-secret
curl -X POST https://api.auth.productcraft.co/v1/apps/<appId>/webhook/rotate-secret \
  -H 'authorization: Bearer pcft_live_...'

The response is the config row with the new signing_secret — again returned exactly once. Auth signs every event dispatched after the rotation with the new secret immediately. Accept both the old and new secret for a brief window so in-flight deliveries don't fail verification, then drop the old.


8

The audit log — the durable record

Every state-changing call on Auth writes an audit_log row. Per-app. Append-only. Searchable + exportable.

Audit captures the same surface as webhooks plus more:

  • Every signup / signin (including failures) / logout / token refresh (auth.*)
  • Every verification + password-reset mint and consume
  • Every member add / remove / role change (app.member.*)
  • Every role CRUD + role-permission change (app.role.*)
  • Every M2M credential mutation (app.m2m.*)
  • Every tenant lifecycle event (tenant.*)
  • Every webhook config change (app.webhook.*)
  • Every MFA factor + recovery-code event (auth.factor.*)
  • Permission denials — on apps with permission enforcement enabled, a request that fails the permission gate writes authz.app_permission_denied with the missing permission

9

Read the audit log

GET /v1/apps/:appId/audit-logs
curl 'https://api.auth.productcraft.co/v1/apps/<appId>/audit-logs?limit=20&action=auth.signin.success' \
  -H 'authorization: Bearer pcft_live_...'
{
  "data": [
    {
      "id":          "...",
      "app_id":      "53641fdb-...",
      "actor_id":    "648616c8-...",   // who did it (account uuid, or null for M2M/system)
      "actor_type":  "end_user",        // | platform_user | m2m | api_key | system
      "action":      "auth.signin.success",
      "resource":    "account",
      "resource_id": "648616c8-...",
      "metadata":    {},
      "ip":          "203.0.113.4",
      "created_at":  "2026-05-11T..."
    }
  ],
  "pagination": { "next_cursor": "...", "has_more": true }
}

Filter via query params: action (exact match, one action per query) and actor_id, plus limit (50 default, 200 max) and cursor for pagination. “Everything Ada did” is ?actor_id=<account>; walk the cursor for time ranges.


10

Webhook vs audit — when to use which

  • Webhook = real-time push. Use for sync that needs to happen immediately when the event fires — flipping a feature flag on the user's account, kicking off provisioning, sending a confirmation in your own UI.
  • Audit = pull, on demand. Use for support workflows (“what happened to this user 3 days ago?”), compliance exports, security investigations, and post-hoc debugging. The data covers everything; the latency is whatever your query takes.
  • Don't treat audit as a stream. There's no “tail the audit log” primitive today — for that, configure a webhook. The audit log is designed for cursor-paginated point-in-time queries.

What's next

You've finished the series

That was the comprehensive tour. From here:

  • API reference — every endpoint, every status code, generated from the live OpenAPI spec.
  • Core concepts — the conceptual model in one page.
  • Platform docs — the administrative surface that sits underneath Auth. Read this if you're managing your workspace from CI or infrastructure-as-code.
  • Mail docs — transactional email, the natural pairing for verification + reset flows.