An administrator registers an endpoint — a URL, a secret the CMS mints, and three filters. When a matching entry changes, the CMS POSTs a signed JSON payload to it. Every attempt is logged and retried on a fixed ladder.
The payload carries references, not content. Read the record back through the public API with your own token, so segment and visibility rules still apply.
Installing it changes nothing
With no endpoint registered nothing is queued and nothing is sent.
Install and register
npm install @orthacms/webhooks-server @orthacms/webhooks-adminBoth ship in every generated app.
// apps/server/src/plugins.ts — after ContentPlugin
import { WebhooksPlugin } from '@orthacms/webhooks-server';
WebhooksPlugin(config.plugins.webhooks);// apps/admin/src/plugins.ts
import { WebhooksPlugin } from '@orthacms/webhooks-admin';
WebhooksPlugin();Tables: webhook_endpoints, webhook_endpoint_workspaces,
webhook_deliveries (migrations under __drizzle_migrations_webhooks).
Configuration
apps/server/config/webhooks.ts reads five variables:
WEBHOOKS_DELIVERY_INTERVAL=2000 # ms between worker ticks
WEBHOOKS_TIMEOUT=10000 # per-request timeout, ms
WEBHOOKS_RETENTION_DAYS=30 # how long finished deliveries stay in the log
WEBHOOKS_ALLOW_INSECURE_URLS=false # permit http://
WEBHOOKS_ALLOW_PRIVATE_NETWORKS=false # permit loopback, link-local, RFC 1918The three numbers must be positive — 0 fails the boot. To use a 0
option, set it in config/webhooks.ts directly.
All plugin options:
| Option | Default | Effect |
|---|---|---|
deliveryIntervalMs | 2000 | Worker poll interval. 0 = this process queues but never sends |
batchSize | 20 | Deliveries one tick claims. Must be > 0 |
timeoutMs | 10000 | Per-request timeout |
maxAttempts | 6 | Attempts before a delivery is dead |
retentionDays | 30 | Log retention. 0 = keep forever |
autoDisableAfter | 20 | Consecutive dead deliveries that disable an endpoint |
allowInsecureUrls | false | Permit http:// |
allowPrivateNetworks | false | Permit private destinations |
claimTimeoutMs | 30000 | When a stuck delivering row is reclaimed. Must exceed timeoutMs |
responseSnippetBytes | 2048 | Response bytes kept in the log |
Apps generated before 0.5.2 ignore these variables
Older templates called WebhooksPlugin() with no argument. Add
config/webhooks.ts and pass it — see the upgrade guide
and configuration.
Events
| Kind | Filtered by content type | Has a workspace |
|---|---|---|
entry.created, entry.updated, entry.published, entry.unpublished | yes | yes |
entry.deleted, entry.restored, entry.purged | yes | yes |
ping — the Send test button; never queued, never counts as a failure | no | no |
GET /api/webhook-events returns the catalogue the running server knows.
Creating an endpoint
Usually in the admin (Webhooks → New webhook). The same route with an administrator’s session:
# cookies.txt holds an admin session from POST /api/auth/login
curl -b cookies.txt https://cms.example.com/api/webhooks \
-H 'Content-Type: application/json' \
-d '{
"name": "Rebuild site",
"url": "https://hooks.example.com/orthacms",
"eventKinds": ["entry.published", "entry.unpublished", "entry.deleted"],
"contentTypes": ["article"],
"workspaceIds": ["6b1f0c2e-8a4d-4f7b-9c3e-2d5a7e9f1b40"],
"headers": { "Authorization": "Bearer receiver-secret" }
}'
# → 201 { "endpoint": { "id": "…", "secretHint": "x9Qa", … }, "secret": "whsec_…" }| Field | Meaning |
|---|---|
name, url | Required |
eventKinds | Empty or omitted = every kind, including future ones |
contentTypes | Empty or omitted = every type |
workspaceIds | The workspaces it listens to |
allWorkspaces | true = every workspace. Default false — must be chosen explicitly |
enabled | Default true |
headers | Custom request headers. No X-Orthacms-*, Host, Content-Type, Content-Length, Transfer-Encoding, Connection, User-Agent |
An event is delivered when it matches all three sets. An entry with no
workspace reaches only allWorkspaces endpoints.
The secret (whsec_ + 32 random bytes, base64url) is returned by create
and rotate only. Everything else shows secretHint, its last four
characters.
What the receiver gets
POST /orthacms HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: Orthacms-Webhooks/1
Authorization: Bearer receiver-secret
X-Orthacms-Event: entry.published
X-Orthacms-Delivery: 0f1c9a2e-6d3b-4a8e-b5f1-2c7e9d4a1b36
X-Orthacms-Event-Id: 9c41d3b8-2e5f-4b7a-8c19-6d0e3f2a5b74
X-Orthacms-Workspace: 6b1f0c2e-8a4d-4f7b-9c3e-2d5a7e9f1b40
X-Orthacms-Attempt: 1
X-Orthacms-Signature: t=1788689524,v1=5d2f8c…{
"id": "0f1c9a2e-6d3b-4a8e-b5f1-2c7e9d4a1b36",
"event": "entry.published",
"eventId": "9c41d3b8-2e5f-4b7a-8c19-6d0e3f2a5b74",
"occurredAt": "2026-09-06T10:12:04.881Z",
"workspaceId": "6b1f0c2e-8a4d-4f7b-9c3e-2d5a7e9f1b40",
"actor": { "id": "d0a2c7e1-4f8b-4c3a-9e62-1b5d7f0a3c98", "email": "editor@example.com" },
"data": {
"kind": "content_entry",
"id": "5ac9e8f2-1d4b-4c7e-9a36-2f8b0d1e7c54",
"contentType": "article",
"title": "Autumn catalogue"
}
}| Header / field | Use it for |
|---|---|
X-Orthacms-Event / event | Routing without parsing the body |
X-Orthacms-Delivery / id | This delivery. A redelivery gets a new one |
X-Orthacms-Event-Id / eventId | Deduplication — stable across retries and redeliveries |
X-Orthacms-Workspace / workspaceId | Omitted / null when the event has none |
X-Orthacms-Attempt | 1-based attempt number |
occurredAt | When it happened, not when it was sent |
actor | null for API-token and system writes |
data.title | A snapshot — survives the entry being deleted |
Verifying a delivery
The signature is HMAC-SHA256 over "{t}.{raw body}", hex, keyed by the
endpoint’s secret. Verify the raw bytes, reject timestamps older than five
minutes, compare in constant time.
import express from 'express';
import { verifySignature } from '@orthacms/webhooks-domain';
const app = express();
const seen = new Set(); // use Redis or your database in production
// express.raw: the signature covers the exact bytes, not a re-serialised object
app.post('/orthacms', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8');
const header = req.get('X-Orthacms-Signature') ?? '';
if (!verifySignature(process.env.ORTHACMS_WEBHOOK_SECRET, header, raw)) {
return res.sendStatus(401); // 4xx = dead, no retry
}
const eventId = req.get('X-Orthacms-Event-Id');
if (seen.has(eventId)) return res.sendStatus(200); // already handled
seen.add(eventId);
const payload = JSON.parse(raw);
res.sendStatus(204); // answer fast; work after
queueRebuild(payload.data.contentType, payload.data.id);
}); import { createHmac, timingSafeEqual } from 'node:crypto';
/**
* @param {string} secret the endpoint's secret, whsec_…
* @param {string} header the X-Orthacms-Signature header, t=…,v1=…
* @param {string} rawBody the request body exactly as received
*/
export function verifyOrthaSignature(secret, header, rawBody, tolerance = 300) {
const parts = Object.fromEntries(
header.split(',').map((part) => part.trim().split('='))
);
const timestamp = Number(parts.t);
const received = (parts.v1 ?? '').toLowerCase();
if (!Number.isInteger(timestamp) || !/^[0-9a-f]+$/.test(received)) {
return false;
}
const age = Math.abs(Math.floor(Date.now() / 1000) - timestamp);
if (age > tolerance) return false;
const expected = createHmac('sha256', secret)
.update(timestamp + '.' + rawBody)
.digest('hex');
if (expected.length !== received.length) return false;
return timingSafeEqual(Buffer.from(expected), Buffer.from(received));
} verifySignature(secret, header, rawBody, now?, toleranceSeconds = 300) from
@orthacms/webhooks-domain is the same code the CMS signs with.
Rotation is immediate, queued deliveries included
POST /api/webhooks/:id/secret signs everything sent afterwards with the new
secret. Deploy the new secret to the receiver first, or deliveries in
between fail your own check.
Retries
| After attempt | Wait (±20 % jitter) |
|---|---|
| 1 | 10 s |
| 2 | 1 min |
| 3 | 5 min |
| 4 | 30 min |
| 5 | 2 h |
| 6 | dead |
| Response | Result |
|---|---|
2xx | succeeded |
408, 429 | Retry. Retry-After honoured, capped at 1 hour |
Other 4xx | dead immediately |
3xx | Not followed; retried as a failure |
5xx, timeout, connection or DNS failure | Retry |
- 20 consecutive dead deliveries disable the endpoint; the reason shows in
the admin. Re-enabling resets the counter. The list tells the two apart: an
endpoint someone paused reads Paused, one the worker switched off
Stopped — and while any is stopped, Home’s Needs your attention shows
a card for it to anyone holding
webhooks:read. - States:
pending→delivering→succeeded|failed(retry scheduled, seenextAttemptAt) |dead. - Finished deliveries are pruned after
retentionDays, at most hourly.
URL policy
Checked on save (422 on refusal) and on every send, after DNS resolution:
https://only, unlessallowInsecureUrls.- No username or password in the URL.
- Public addresses only, unless
allowPrivateNetworks: loopback, link-local (incl.169.254.169.254), RFC 1918, CGNAT, test, multicast and reserved ranges are refused, IPv4-mapped IPv6 included.
Admin API
All under /api, global (no X-Workspace-Id), session-authenticated.
| Route | Needs |
|---|---|
GET /api/webhooks, GET /api/webhooks/:id | webhooks:read |
POST /api/webhooks, PATCH/DELETE /api/webhooks/:id — PATCH takes the full body, like create | webhooks:manage |
POST /api/webhooks/:id/secret — rotate, returns { endpoint, secret } | webhooks:manage |
POST /api/webhooks/:id/test — returns { ok, statusCode, error, durationMs, responseSnippet } | webhooks:manage |
GET /api/webhooks/:id/deliveries?status=&eventKind=&page=&pageSize= | webhooks:read |
GET /api/webhooks/:id/deliveries/:deliveryId | webhooks:read |
POST /api/webhooks/:id/deliveries/:deliveryId/redeliver — new delivery id, same event id | webhooks:manage |
GET /api/webhook-events | webhooks:read |
Both keys are admin-only — an endpoint reaches across workspaces and its
log records where the installation talks to. A delivery id from another
endpoint is a 404. Full table: endpoint reference.
Limits
What this is not
- Not a message bus. At-least-once and unordered: you can get the same
event twice, or
entry.updatedafterentry.published. Deduplicate onX-Orthacms-Event-Idand treat every delivery as “go and look”. - Entry events and
pingonly. No media, transfer, account, workspace or custom events. - No payload shaping and no inlined field values.
- No replay of history — an endpoint hears about changes after it exists.
- In-process worker. Every API process sends. Set
deliveryIntervalMs: 0on nodes that should only enqueue.
See reacting to changes for cache invalidation and rebuild patterns.