Webhooks (webhooks)
Outgoing webhooks for your API. Your users (and admins) register a URL, pick events like order.created or order.*, and receive an HTTP POST every time a matching record changes. Each user only receives records they are allowed to read, serialized exactly like the REST API returns them to that user. Requests are signed with the Standard Webhooks scheme, queued in MongoDB, retried with backoff and logged per endpoint. URLs that point at localhost, private networks or cloud metadata services are refused in production. No dependencies.
npx calm add webhooks| Adds | |
|---|---|
| Files | app/blocks/webhooks/index.js (routes, events, doctor, devtools), delivery.js (queue and sender), signing.js (signatures and verifyWebhook), ssrf.js (URL safety) |
| Environment | WEBHOOKS_WORKER=on (set off on instances that should not send webhooks) |
| Collections | calm_webhooks (endpoints), calm_webhook_deliveries (queue and delivery log, cleaned up by a TTL index) |
| Routes | /api/webhooks (see API) |
Set encryption keys so signing secrets and queued payloads are encrypted at rest:
npx calm keygen --writeQuick start#
Create an endpoint as a signed-in user. The response is the only time the signing secret is shown:
curl -X POST http://localhost:5001/api/webhooks \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{ "url": "https://example.com/hooks/calm", "events": ["post.created", "post.updated"] }'{
"data": {
"id": "6650c0ffee0000000000abcd",
"url": "https://example.com/hooks/calm",
"events": ["post.created", "post.updated"],
"scope": "own",
"enabled": true,
"status": "active",
"secret": "whsec_<shown once>"
}
}Send yourself a test event, then look at the delivery log:
curl -X POST http://localhost:5001/api/webhooks/6650c0ffee0000000000abcd/test -H "Authorization: Bearer $TOKEN"
curl http://localhost:5001/api/webhooks/6650c0ffee0000000000abcd/deliveries -H "Authorization: Bearer $TOKEN"Event names are <resource>.created, <resource>.updated, <resource>.deleted (and <resource>.restored with the soft-delete block). Use <resource>.* for all four, or * for every resource. Unknown resources and events answer 422 with the list of valid ones.
What receivers get#
A POST with a JSON body and three headers:
POST /hooks/calm HTTP/1.1
content-type: application/json
webhook-id: msg_<random id>
webhook-timestamp: 1767225600
webhook-signature: v1,<base64 signature>
{"type":"post.created","timestamp":"2026-01-01T00:00:00.000Z","data":{"id":"6650...","title":"Hello","status":"published","author":"6650...","createdAt":"...","updatedAt":"..."}}webhook-idis the same on every retry of a message: store it and skip duplicates.webhook-timestampis the time of this attempt, so old messages cannot be replayed.webhook-signatureisv1,followed by the base64 HMAC-SHA256 ofid.timestamp.body, keyed with the base64-decoded part of the secret afterwhsec_. During a secret rotation it holds two signatures separated by a space.datais the record as the endpoint owner would see it fromGET /api/<resource>/:id. Deleted records are sent as they were just before deletion.
Answer with any 2xx status quickly and do slow work in the background. Anything else, a redirect, or no answer within 10 seconds counts as a failed attempt.
Verifying signatures#
The block exports verifyWebhook(secret, headers, rawBody). It checks the signature in constant time and refuses timestamps more than 5 minutes away from now. It returns the parsed payload or throws a WebhookVerificationError. It needs the raw body: parsing the JSON and serializing it again changes the bytes.
app/blocks/webhooks/signing.js only imports node:crypto, so copy it into the app that receives the webhooks. Any Standard Webhooks library (for example the standardwebhooks npm package) works too.
import express from 'express';
import { verifyWebhook } from './signing.js';
const app = express();
app.post('/hooks/calm', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = verifyWebhook(process.env.CALM_WEBHOOK_SECRET, req.headers, req.body);
} catch {
return res.sendStatus(401);
}
if (event.type === 'post.created') queuePostSync(event.data);
res.sendStatus(204);
});With plain node:http or a fetch-style handler, pass the body as a string or Buffer and the headers as they are (req.headers or a Headers object).
Who receives what#
- Own endpoints (the default) receive a record only if their owner may read it through the API: the resource's
readpolicy, owner scoping ('owner') and the servicescope()(drafts, tenants...). This isapp.canRead(). The payload is serialized for that owner, sohidden()fields never appear andvisibleTo()fields only appear for users allowed to see them. With theapitemplate, Bob's endpoint does not receive Alice's draft posts, and receives the post once she publishes it. - Global endpoints receive every record of the events they follow, whatever its read policy, serialized with the admin's view (hidden fields still never appear). The service
scope()still applies, so in a multi-tenant app a tenant admin only receives their tenant's records. Only users matchingglobalPolicy(default'admin') can create them, and they stop receiving events if their owner loses that role:
{ "url": "https://ops.example.com/hooks", "events": ["*"], "scope": "global" }Endpoints belong to their owner: other users get 404 for every route of an endpoint that is not theirs. Disabled users receive nothing, and a deleted user's endpoints and delivery log are removed.
Keep a resource out of webhooks entirely with webhooks: false in its resource() options.
Retries and failures#
Deliveries are queued in MongoDB and sent by a worker in every instance. A delivery is claimed atomically, so it is never sent twice. A failed attempt is retried after 5 s, 30 s, 2 min, 10 min, 30 min, 2 h and 6 h (each with 20% random jitter, and later when the receiver sends Retry-After), then the delivery is marked failed.
410 Goneturns the endpoint off at once: the receiver is asking you to stop.- After
disableAfterFailuresdeliveries in a row (default 5) fail every retry, the endpoint is turned off. - When an endpoint is turned off, the block emits
webhook.disabledwith{ webhook, owner, reason }('gone','failing'or'manual') and emails the owner (turn that off withnotifyOwner: false).
// In any block: also alert your team
calm.on('webhook.disabled', ({ webhook, reason }) => {
if (reason !== 'manual') calm.log.warn(`Webhook ${webhook.id} turned off: ${reason}`);
});The owner turns it back on with PATCH /api/webhooks/:id { "enabled": true }, which also resets its failure counters.
The delivery log#
GET /api/webhooks/:id/deliveries?status=failed&limit=20 returns the latest deliveries, newest first:
{
"id": "6650...",
"type": "post.updated",
"messageId": "msg_<random id>",
"status": "succeeded",
"attempts": 3,
"statusCode": 200,
"durationMs": 84,
"error": null,
"nextAttemptAt": null,
"response": "ok",
"payload": "{\"type\":\"post.updated\",...}",
"payloadTruncated": false,
"attemptLog": [
{ "at": "...", "statusCode": 500, "durationMs": 12, "error": "HTTP 500" },
{ "at": "...", "statusCode": 500, "durationMs": 9, "error": "HTTP 500" },
{ "at": "...", "statusCode": 200, "durationMs": 84, "error": null }
]
}status is pending, sending, succeeded or failed. The receiver's answer is kept up to 1 KB and the payload preview up to 2 KB. Entries are deleted keepDeliveriesDays (default 14) after they finish. POST /api/webhooks/:id/deliveries/:deliveryId/retry sends one again right away, with the same webhook-id and a fresh retry schedule.
Rotating the secret#
curl -X POST http://localhost:5001/api/webhooks/6650c0ffee0000000000abcd/rotate-secret -H "Authorization: Bearer $TOKEN"The response shows the new secret, once. For secretGraceHours (default 24) every message is signed with both the new and the old secret, so receivers can switch without missing anything.
Secrets must be readable to sign messages, so they cannot be hashed. With CALM_ENCRYPTION_KEYS set they are encrypted at rest with AES-256-GCM, together with queued payloads (which can contain fields your resources store .encrypted()).
SSRF protection#
Webhook URLs are typed by users and called by your server, so they are checked twice: when an endpoint is saved, and on every delivery.
- Only
http://andhttps://, and no user name or password in the URL. In production,https://only (requireHttps). - In production, hosts must be public. Refused:
localhost, loopback (127.0.0.0/8,::1), private networks (10/8,172.16/12,192.168/16,fc00::/7), link-local and cloud metadata addresses (169.254.169.254,fd00:ec2::254,100.100.100.200,192.0.0.192), carrier-grade NAT (100.64/10), multicast, reserved and documentation ranges, and IPv6 forms that carry such an IPv4 address (::ffff:127.0.0.1, NAT64, 6to4). - Host names are resolved and every address must be public. When sending, the connection goes to the address that was checked, so a DNS answer that changes in between (DNS rebinding) cannot reach your network.
- Redirects are never followed: a
3xxanswer is a failed attempt.
In development and tests (allowPrivate, on by default outside production) local receivers such as http://localhost:4000/hooks work. calm doctor warns if allowPrivate is on in production.
Settings#
Set them in calm.config.js:
export default defineConfig({
blocks: {
webhooks: {
maxEndpointsPerUser: 5,
retrySchedule: ['10s', '1m', '10m', '1h', '6h', '24h'],
policy: 'admin', // only admins manage webhooks
},
},
});| Option | Default | |
|---|---|---|
policy | 'authenticated' | Who may create and manage their own endpoints |
globalPolicy | 'admin' | Who may create scope: 'global' endpoints |
allowPrivate | on in development and test | Allow localhost and private addresses |
requireHttps | on in production | Refuse http:// URLs |
maxEndpointsPerUser | 10 | Endpoints per user (409 LIMIT_REACHED beyond) |
maxEventsPerEndpoint | 20 | Event names per endpoint |
maxUrlLength | 2048 | Characters |
retrySchedule | ['5s', '30s', '2m', '10m', '30m', '2h', '6h'] | Waits between attempts: milliseconds, or '30s', '5m', '2h', '1d' |
timeoutMs | 10000 | Time a receiver has to answer |
pollMs | 1000 | How often the worker looks for due deliveries |
concurrency | 10 | Deliveries in flight per instance |
disableAfterFailures | 5 | Failed deliveries in a row before the endpoint is turned off (0: never) |
keepDeliveriesDays | 14 | Delivery log retention |
secretGraceHours | 24 | How long the old secret keeps signing after a rotation |
notifyOwner | true | Email the owner when an endpoint is turned off automatically |
| Variable | |
|---|---|
WEBHOOKS_WORKER | off on instances that should not send deliveries (for example web servers when a separate worker process runs). Default on. |
API#
| Route | |
|---|---|
POST /api/webhooks | Create { url, events, description?, scope? }. Returns the secret, once. |
GET /api/webhooks | Your endpoints |
GET /api/webhooks/:id | One endpoint: status, last success, last error |
PATCH /api/webhooks/:id | Change url, events, description, enabled |
DELETE /api/webhooks/:id | Delete the endpoint and its delivery log |
POST /api/webhooks/:id/rotate-secret | New secret, shown once |
POST /api/webhooks/:id/test | Send a webhook.test event now and return the delivery |
GET /api/webhooks/:id/deliveries | Recent deliveries (?status=, ?limit= up to 100, default 50) |
POST /api/webhooks/:id/deliveries/:deliveryId/retry | Send a delivery again now |
test and retry are rate limited (10 a minute per client), since they make your server send requests. A disabled endpoint answers 400 to both until it is turned back on.
From server code, app.ext.webhooks gives the Webhook and Delivery models, drain() (send everything that is due now, handy in tests) and verifyWebhook.
Devtools, doctor and production#
- The devtools Webhooks panel lists endpoints with their status, the latest deliveries with status codes, errors and next retries, and how many deliveries are pending.
calm doctorreports how many endpoints exist and warns about endpoints that are failing right now, aboutallowPrivatein production, and about secrets stored withoutCALM_ENCRYPTION_KEYSin production.- With several instances, every instance sends deliveries from the shared queue. Set
WEBHOOKS_WORKER=offwhere it should not. - Deliveries are not ordered: use
timestamp(or fetch the record) when order matters.
Testing#
Test apps run the worker too, and local receivers are allowed in tests. Point an endpoint at a local server, then call drain() instead of waiting for the next poll:
const t = await createTestApp({
root: join(import.meta.dirname, '..'),
config: { blocks: { webhooks: { retrySchedule: [20, 20] } } }, // retries in milliseconds
});
const alice = await t.signup();
await t.post('/webhooks', { url: `${receiverUrl}/hooks`, events: ['post.created'] }, alice);
await t.post('/posts', { title: 'Hi', body: '...' }, alice);
await t.idle(); // event listeners have queued the deliveries
await t.app.ext.webhooks.drain(); // and they are sentreceiverUrl is a small node:http server your test starts on a random port. Check what it received with verifyWebhook() and the secret from the create response.