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.

Shell
npx calm add webhooks
Adds
Filesapp/blocks/webhooks/index.js (routes, events, doctor, devtools), delivery.js (queue and sender), signing.js (signatures and verifyWebhook), ssrf.js (URL safety)
EnvironmentWEBHOOKS_WORKER=on (set off on instances that should not send webhooks)
Collectionscalm_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:

Shell
npx calm keygen --write

Quick start#

Create an endpoint as a signed-in user. The response is the only time the signing secret is shown:

Shell
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"] }'
JSON
{
  "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:

Shell
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:

HTTP
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-id is the same on every retry of a message: store it and skip duplicates.
  • webhook-timestamp is the time of this attempt, so old messages cannot be replayed.
  • webhook-signature is v1, followed by the base64 HMAC-SHA256 of id.timestamp.body, keyed with the base64-decoded part of the secret after whsec_. During a secret rotation it holds two signatures separated by a space.
  • data is the record as the endpoint owner would see it from GET /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.

JavaScript
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 read policy, owner scoping ('owner') and the service scope() (drafts, tenants...). This is app.canRead(). The payload is serialized for that owner, so hidden() fields never appear and visibleTo() fields only appear for users allowed to see them. With the api template, 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 matching globalPolicy (default 'admin') can create them, and they stop receiving events if their owner loses that role:
JSON
{ "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 Gone turns the endpoint off at once: the receiver is asking you to stop.
  • After disableAfterFailures deliveries in a row (default 5) fail every retry, the endpoint is turned off.
  • When an endpoint is turned off, the block emits webhook.disabled with { webhook, owner, reason } ('gone', 'failing' or 'manual') and emails the owner (turn that off with notifyOwner: false).
JavaScript
// 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:

JSON
{
  "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#

Shell
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:// and https://, 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 3xx answer 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:

JavaScript
export default defineConfig({
  blocks: {
    webhooks: {
      maxEndpointsPerUser: 5,
      retrySchedule: ['10s', '1m', '10m', '1h', '6h', '24h'],
      policy: 'admin', // only admins manage webhooks
    },
  },
});
OptionDefault
policy'authenticated'Who may create and manage their own endpoints
globalPolicy'admin'Who may create scope: 'global' endpoints
allowPrivateon in development and testAllow localhost and private addresses
requireHttpson in productionRefuse http:// URLs
maxEndpointsPerUser10Endpoints per user (409 LIMIT_REACHED beyond)
maxEventsPerEndpoint20Event names per endpoint
maxUrlLength2048Characters
retrySchedule['5s', '30s', '2m', '10m', '30m', '2h', '6h']Waits between attempts: milliseconds, or '30s', '5m', '2h', '1d'
timeoutMs10000Time a receiver has to answer
pollMs1000How often the worker looks for due deliveries
concurrency10Deliveries in flight per instance
disableAfterFailures5Failed deliveries in a row before the endpoint is turned off (0: never)
keepDeliveriesDays14Delivery log retention
secretGraceHours24How long the old secret keeps signing after a rotation
notifyOwnertrueEmail the owner when an endpoint is turned off automatically
Variable
WEBHOOKS_WORKERoff on instances that should not send deliveries (for example web servers when a separate worker process runs). Default on.

API#

Route
POST /api/webhooksCreate { url, events, description?, scope? }. Returns the secret, once.
GET /api/webhooksYour endpoints
GET /api/webhooks/:idOne endpoint: status, last success, last error
PATCH /api/webhooks/:idChange url, events, description, enabled
DELETE /api/webhooks/:idDelete the endpoint and its delivery log
POST /api/webhooks/:id/rotate-secretNew secret, shown once
POST /api/webhooks/:id/testSend a webhook.test event now and return the delivery
GET /api/webhooks/:id/deliveriesRecent deliveries (?status=, ?limit= up to 100, default 50)
POST /api/webhooks/:id/deliveries/:deliveryId/retrySend 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 doctor reports how many endpoints exist and warns about endpoints that are failing right now, about allowPrivate in production, and about secrets stored without CALM_ENCRYPTION_KEYS in production.
  • With several instances, every instance sends deliveries from the shared queue. Set WEBHOOKS_WORKER=off where 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:

JavaScript
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 sent

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