Skip to content

Webhooks

Register webhooks to receive HTTP callbacks when tenants, config, or permissions change. Deliveries include HMAC-SHA256 signatures and automatic retry with exponential backoff.

Event Trigger
tenant.created New tenant created
tenant.updated Tenant properties changed
tenant.deleted Tenant archived
tenant.moved Tenant moved in hierarchy
tenant.activated Pending tenant activated after its storage is provisioned
tenant.suspended Tenant suspended
tenant.resumed Suspended or archived tenant resumed
tenant.archived Tenant archived
tenant.purged Deprecated. purgeTenant never emits it, because the purge erases the event log of the tenant. The next major version removes it.
config.updated Config key set or overridden
config.deleted Config key removed
permission.created Permission policy created
permission.updated Permission policy updated
permission.deleted Permission policy deleted
const webhook = await stratum.createWebhook({
url: "https://your-app.com/webhooks/stratum",
tenant_id: tenantId, // null for global webhooks
events: ["tenant.created", "config.updated"],
secret: "your-signing-secret",
});

Via the API:

curl -X POST http://localhost:3001/api/v1/webhooks \
-H "X-API-Key: YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/webhooks/stratum",
"tenant_id": "TENANT_UUID",
"events": ["tenant.created", "config.updated"],
"secret": "your-signing-secret"
}'

The secret is encrypted at rest using AES-256-GCM. It is used to generate HMAC-SHA256 signatures for each delivery.

Each webhook delivery is an HTTP POST to your URL with these headers:

Header Value
Content-Type application/json
X-Stratum-Event Event type (for example tenant.created)
X-Stratum-Signature sha256=<hmac_hex>
X-Stratum-Timestamp The time of this attempt, as an ISO 8601 string
X-Stratum-Delivery-ID The delivery ID. It stays the same on every retry of a delivery, so you can use it to drop duplicates.

The body is the event:

{
"id": "event-uuid",
"type": "tenant.created",
"tenant_id": "tenant-uuid",
"data": {
"tenant": {
"id": "tenant-uuid",
"name": "Acme Corp",
"slug": "acme_corp",
"parent_id": null,
"isolation_strategy": "SHARED_RLS"
}
},
"created_at": "2024-06-15T10:30:00.000Z"
}

data depends on the event. For the tenant events it holds the tenant.

The signature is an HMAC-SHA256, keyed with the webhook secret, over the timestamp, a dot and the raw body: <X-Stratum-Timestamp>.<body>. Because the timestamp is signed, a captured delivery cannot be sent again with a new timestamp.

Use verifyWebhookSignature from @stratum-hq/lib. It checks the signature in constant time and also rejects a timestamp outside the freshness window (300 seconds by default, toleranceSeconds to change it). It returns false instead of throwing. Pass it the body exactly as it arrived, not a re-serialized req.body:

import express from "express";
import { verifyWebhookSignature } from "@stratum-hq/lib";
const app = express();
const webhookSecret = process.env.WEBHOOK_SECRET;
if (!webhookSecret) {
throw new Error("WEBHOOK_SECRET is required to verify deliveries.");
}
// Keep the raw body: the signature covers the exact bytes that were sent.
app.post("/webhooks/stratum", express.raw({ type: "application/json" }), (req, res) => {
const payload = req.body.toString("utf8");
const valid = verifyWebhookSignature({
secret: webhookSecret,
payload,
signature: req.get("X-Stratum-Signature") ?? "",
timestamp: req.get("X-Stratum-Timestamp") ?? "",
});
if (!valid) {
res.status(401).send("Invalid signature");
return;
}
const event = JSON.parse(payload);
// Process the event. Use the X-Stratum-Delivery-ID header to drop duplicates.
res.status(200).send("OK");
});

signWebhookPayload(secret, timestamp, payload) is exported too, if you need to produce a signature, for example in a test.

Stratum records each event, then creates its deliveries in the background. An event goes to each webhook that meets all of these conditions:

  • The webhook is active.
  • The webhook subscribes to the event type.
  • The webhook belongs to the event’s tenant, or it has no tenant.
  • Stratum created the webhook at or before the time it recorded the event.

A webhook that you register after an event gets no delivery for that event, even when the background work runs after the registration.

Stratum reads the subscription and the active flag of a webhook when it creates the deliveries, not as they were at the time of the event. A change to events or active in that short interval can add or remove a delivery for an event that Stratum recorded just before the change.

A delivery fails on a non-2xx response or a network error. Stratum makes up to 5 attempts. After a failed attempt n, it waits n² × 5 seconds before the next one (5, 20, 45 and 80 seconds). A delivery that fails its fifth attempt is marked failed and lands in the dead-letter queue.

Send a test event to verify your endpoint:

curl -X POST http://localhost:3001/api/v1/webhooks/WEBHOOK_ID/test \
-H "X-API-Key: YOUR_KEY"

This sends a synthetic webhook.test event, signed and with the same headers as a real delivery, and returns { "success": true, "response_code": 200 } (or the failure). The test is not recorded as a delivery.

View delivery history for a specific webhook:

curl http://localhost:3001/api/v1/webhooks/WEBHOOK_ID/deliveries \
-H "X-API-Key: YOUR_KEY"

Failed deliveries that exhaust their retry attempts land in the dead-letter queue (DLQ).

curl http://localhost:3001/api/v1/webhooks/deliveries/stats \
-H "X-API-Key: YOUR_KEY"

Response:

{
"total": 150,
"pending": 5,
"success": 130,
"failed": 15
}
curl "http://localhost:3001/api/v1/webhooks/deliveries/failed?limit=50" \
-H "X-API-Key: YOUR_KEY"

Retry a single failed delivery:

curl -X POST http://localhost:3001/api/v1/webhooks/deliveries/DELIVERY_ID/retry \
-H "X-API-Key: YOUR_KEY"

Retry all failed deliveries:

curl -X POST http://localhost:3001/api/v1/webhooks/deliveries/retry-all \
-H "X-API-Key: YOUR_KEY"
curl -X PATCH http://localhost:3001/api/v1/webhooks/WEBHOOK_ID \
-H "X-API-Key: YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"events": ["tenant.created", "tenant.updated", "config.updated"]}'
curl -X DELETE http://localhost:3001/api/v1/webhooks/WEBHOOK_ID \
-H "X-API-Key: YOUR_KEY"

Webhook URLs are validated to prevent Server-Side Request Forgery:

  • DNS resolution is checked to block private IP ranges
  • IPv4 and IPv6 private, loopback, link-local and other IANA special-purpose ranges are blocked (10.x, 172.16-31.x, 192.168.x, 100.64.0.0/10, the TEST-NET and benchmarking ranges, multicast and reserved 224.0.0.0/3, ::1, fc00::/7, NAT64, 6to4, Teredo, etc.)
  • Cloud metadata endpoints are blocked (169.254.169.254, metadata.google.internal)
  • DNS rebinding attacks are mitigated by resolving the hostname at delivery time