Heimdall 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 Heimdall fires:

Event types
# 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.deleted

Webhooks 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.

PUT /v1/apps/:appId/webhook
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.

Express/Node
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.

GET /v1/apps/:appId/webhook/attempts
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.

POST /v1/apps/:appId/webhook/rotate-secret
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 @RequireAppPermission guard and fails, that writes authz.app_permission_denied with the missing perm + the reason

9

Read the audit log

GET /v1/apps/:appId/audit-logs
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.