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 Heimdall fires:
# Account lifecycle
user.created # signup
user.email_verified
user.status_changed
user.role_changed
user.deleted
# Auth events
user.signin # successful signin
user.password_changed
user.password_reset_completed
# Tenants
tenant.created
tenant.updated
tenant.deleted
tenant.member.added
tenant.member.role_changed
tenant.member.removed
# M2M
m2m.credential.created
m2m.credential.rotated
m2m.credential.deletedWebhooks are per-app — each app has one webhook URL + one signing secret. If you run several apps, configure one per app. (One workspace-wide aggregator endpoint is on the roadmap; not shipped today.)
2
Configure your webhook
From the console (App → Webhooks → Configure) or via the heimdall-admin API.
curl -X PUT https://api.heimdall.productcraft.co/v1/apps/<appId>/webhook \
-H 'authorization: Bearer pcft_live_...' \
-H 'content-type: application/json' \
-d '{
"url": "https://acme.example.com/webhooks/heimdall",
"events": ["user.created", "user.email_verified", "user.role_changed"],
"description": "Sync the user graph + role changes into Acme."
}'{
"url": "https://acme.example.com/webhooks/heimdall",
"secret": "wh_secret_thatappearsonce", # rotate via .../rotate-secret
"events": ["user.created", "user.email_verified", "user.role_changed"],
"description": "Sync the user graph + role changes into Acme.",
"is_active": true,
"created_at": "2026-05-11T..."
}secret is returned exactly once. 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-Heimdall-Signature header.
POST https://acme.example.com/webhooks/heimdall
content-type: application/json
x-heimdall-event: user.email_verified
x-heimdall-delivery: whd_a1b2c3d4
x-heimdall-timestamp: 1778499378
x-heimdall-signature: t=1778499378,v1=...hex...
{
"event": "user.email_verified",
"occurred_at": "2026-05-11T11:38:42Z",
"app_id": "53641fdb-...",
"data": {
"account_id": "648616c8-...",
"email": "ada@example.com",
"email_verified_at": "2026-05-11T11:38:42Z"
}
}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.HEIMDALL_WEBHOOK_SECRET!;
const MAX_AGE_S = 300;
app.post('/webhooks/heimdall', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.header('x-heimdall-signature') ?? '';
const ts = Number(req.header('x-heimdall-timestamp') ?? 0);
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 m = /t=(\d+),v1=([0-9a-f]+)/i.exec(sig);
if (!m) return res.status(400).send('malformed signature');
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 Heimdall signed.
5
Retry policy
Heimdall expects a 2xx within 30 seconds. Anything else triggers retries with exponential backoff.
Schedule:
- Attempt 1 — immediately on event
- Attempt 2 — 1 minute later
- Attempt 3 — 5 minutes later
- Attempt 4 — 30 minutes later
- Attempt 5 — 6 hours later
- After 5 failed attempts the webhook is auto-disabled.
Once auto-disabled, no more events fire until you re-enable from the console. The status surfaces in the console as “Auto-disabled at … due to 5 consecutive failures” with a link to the per-attempt log.
Be idempotent. A retry might land after you successfully processed the first attempt (your 2xx got lost). Use the x-heimdall-delivery header as the idempotency key — it's stable across retries of the same event.
6
Inspect delivery attempts
The console shows every attempt with status code, latency, response body. Also available via the API.
curl https://api.heimdall.productcraft.co/v1/apps/<appId>/webhook/attempts?limit=20 \
-H 'authorization: Bearer pcft_live_...'{
"data": [
{
"delivery_id": "whd_a1b2c3d4",
"event": "user.email_verified",
"attempt": 1,
"url": "https://acme.example.com/webhooks/heimdall",
"status_code": 204,
"latency_ms": 148,
"delivered_at": "2026-05-11T11:38:43Z"
},
{
"delivery_id": "whd_b9c8d7e6",
"event": "user.signin",
"attempt": 2,
"url": "https://acme.example.com/webhooks/heimdall",
"status_code": 500,
"latency_ms": 30001, # timed out
"error": "Connect ETIMEDOUT 172.16.0.42:443",
"delivered_at": "2026-05-11T11:39:02Z"
}
],
"pagination": { "next_cursor": "...", "has_more": true }
}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.heimdall.productcraft.co/v1/apps/<appId>/webhook/rotate-secret \
-H 'authorization: Bearer pcft_live_...'Choreography matters. Heimdall signs every new event with the new secret immediately. Your service should accept BOTH the old + new secret for a brief window (10 minutes) so in-flight events don't fail verification. Then drop the old.
8
The audit log — the durable record
Every state-changing call on Heimdall writes an audit_log row. Per-app. Append-only. Searchable + exportable.
Audit captures the same surface as webhooks plus more:
- Every signup / signin / signout / token refresh
- Every password change + reset
- Every member add / remove / role change
- Every role + permission CRUD action
- Every M2M credential mutation
- Every tenant lifecycle event
- Every webhook config change
- Every permission denial — when a request hits a
@RequireAppPermissionguard and fails, that writesauthz.app_permission_deniedwith the missing perm + the reason
9
Read the audit log
curl https://api.heimdall.productcraft.co/v1/apps/<appId>/audit-logs?limit=20&action=user.role_changed \
-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": "user.role_changed",
"resource": "user",
"resource_id": "...",
"metadata": {
"from_role": "member",
"to_role": "admin",
"by_user": "..."
},
"ip": "203.0.113.4",
"created_at": "2026-05-11T..."
}
],
"pagination": { "next_cursor": "...", "has_more": true }
}Filter via query params: action, actor_id, resource_id, since, until. Combine for answers like “everything Ada did last week” or “every permission denial on this customer's workspace today.”
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 Heimdall. Read this if you're managing your workspace from CI or infrastructure-as-code.
- Envoi docs — transactional email, the natural pairing for verification + reset flows.