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:
# 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 aliasWebhooks 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://.
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.
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_atanddisabled_reasonare set on the config and no more events fire. Re-enable by re-upserting the config (PUT .../webhookclears 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-idheader (alsoidin 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.
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.
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_deniedwith the missing permission
9
Read the audit log
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.