Skip to Content

Webhooks

Webhooks let your backend react to events in your Faable Auth tenant — without polling. Each event is delivered as a signed HTTPS POST to a URL you choose. Faable signs the payload with HMAC-SHA256 and includes a timestamp so you can reject replays.

Supported events

EventWhen
user.createdA user record is added to the tenant.
user.updatedA user record is updated (email change, profile edit, metadata change…).
user.deletedA user record is removed.
auth.loginA user successfully authenticates.

[!IMPORTANT] Plan requirement: Webhooks are available on Pro and Business. Hobby accounts cannot create webhook subscriptions. See Auth pricing.

Managing subscriptions

Webhook subscriptions live on the notification_subscriptions resource. Filter by ?channel=webhook to see only webhook subscriptions (the resource is shared with email subscriptions).

MethodPathPurpose
POST/notification_subscriptionsCreate a webhook.
GET/notification_subscriptions?channel=webhookList webhooks.
POST/notification_subscriptions/:notification_subscription_idUpdate.
DELETE/notification_subscriptions/:notification_subscription_idDelete.

Create example

POST /notification_subscriptions Content-Type: application/json { "channel": "webhook", "url": "https://api.example.com/hooks/faable", "secret": "whsec_<long-random-string>", "events": ["user.created", "user.updated", "auth.login"] }

Pick a strong, unguessable secret (e.g. 32+ random bytes, base64-encoded). Faable uses it to sign every delivery — anyone who knows it can forge a request.

Delivery format

POST /hooks/faable HTTP/1.1 Host: api.example.com Content-Type: application/json X-Faable-Event: user.created X-Faable-Delivery: evt_2t7… X-Faable-Timestamp: 1747353000123 X-Faable-Signature: sha256=4f3c8e… { "id": "evt_2t7…", "type": "user.created", "produced_at": "2026-05-12T10:30:00.123Z", "payload": { "user": { "user_id": "usr_…", "email": "ada@example.com" } } }

Headers

HeaderMeaning
X-Faable-EventEvent type (matches the type field in the body).
X-Faable-DeliveryUnique event ID for idempotency on your side.
X-Faable-TimestampUnix milliseconds when Faable computed the signature.
X-Faable-Signaturesha256=<hex> HMAC-SHA256 of ${timestamp}.${rawBody} using your secret.

Delivery semantics

  • HTTP POST with a 5-second timeout.
  • Non-2xx responses and timeouts are recorded as failed in Logs. There is no automatic retry — design your handler to acknowledge quickly (return 2xx) and process out-of-band if needed.
  • Bodies on both sides are truncated at 64 KB for logging.

Verifying signatures

Verify every request. The signed string is ${timestamp}.${rawBody} — make sure you use the raw body bytes (before any JSON parsing) and that you compare in constant time. Reject requests whose timestamp is more than a few minutes off your clock to prevent replay attacks.

Node / TypeScript

import { createHmac, timingSafeEqual } from "node:crypto"; export function verifyFaableSignature( rawBody: string, headers: Record<string, string>, secret: string, ): boolean { const timestamp = headers["x-faable-timestamp"]; const signatureHeader = headers["x-faable-signature"] ?? ""; const [, hex] = signatureHeader.split("="); if (!timestamp || !hex) return false; // Reject replays older than 5 minutes. const ageMs = Math.abs(Date.now() - Number(timestamp)); if (ageMs > 5 * 60 * 1000) return false; const expected = createHmac("sha256", secret) .update(`${timestamp}.${rawBody}`) .digest(); const provided = Buffer.from(hex, "hex"); return ( provided.length === expected.length && timingSafeEqual(provided, expected) ); }

Using it in an Express route

import express from "express"; const app = express(); // Capture the raw body — Faable signs the bytes, not a re-serialized JSON. app.post( "/hooks/faable", express.raw({ type: "application/json" }), (req, res) => { const raw = req.body.toString("utf8"); const ok = verifyFaableSignature(raw, req.headers as any, process.env.FAABLE_WEBHOOK_SECRET!); if (!ok) return res.status(401).send("invalid signature"); const event = JSON.parse(raw); // …process event.id idempotently… res.status(200).send("ok"); }, );

[!IMPORTANT] If your framework parses JSON before you compute the HMAC, the signature will not match (whitespace differences invalidate it). Always sign over the raw bytes.

Next steps

  • Logs — inspect successful and failed deliveries.
  • Actions — for cases where you need to influence the auth flow instead of reacting after the fact.
Last updated on