Webhooks
Register webhooks to receive HTTP callbacks when tenants, config, or permissions change. Deliveries include HMAC-SHA256 signatures and automatic retry with exponential backoff.
Supported events
Section titled “Supported events”| 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 |
Creating webhooks
Section titled “Creating webhooks”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.
Delivery format
Section titled “Delivery format”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.
Verifying signatures
Section titled “Verifying signatures”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.
Which webhooks receive an event
Section titled “Which webhooks receive an event”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.
Retry logic
Section titled “Retry logic”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.
Testing webhooks
Section titled “Testing webhooks”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.
Delivery history
Section titled “Delivery history”View delivery history for a specific webhook:
curl http://localhost:3001/api/v1/webhooks/WEBHOOK_ID/deliveries \ -H "X-API-Key: YOUR_KEY"Dead-letter queue
Section titled “Dead-letter queue”Failed deliveries that exhaust their retry attempts land in the dead-letter queue (DLQ).
View delivery stats
Section titled “View delivery stats”curl http://localhost:3001/api/v1/webhooks/deliveries/stats \ -H "X-API-Key: YOUR_KEY"Response:
{ "total": 150, "pending": 5, "success": 130, "failed": 15}List failed deliveries
Section titled “List failed deliveries”curl "http://localhost:3001/api/v1/webhooks/deliveries/failed?limit=50" \ -H "X-API-Key: YOUR_KEY"Retry failed deliveries
Section titled “Retry failed deliveries”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"Managing webhooks
Section titled “Managing webhooks”Update
Section titled “Update”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"]}'Delete
Section titled “Delete”curl -X DELETE http://localhost:3001/api/v1/webhooks/WEBHOOK_ID \ -H "X-API-Key: YOUR_KEY"SSRF protection
Section titled “SSRF protection”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