The API is pull. The webhooks plugin pushes: an administrator registers your URL, and Ortha POSTs a signed envelope when a matching entry changes. This page is the receiving end.
Poll or subscribe?
Poll when you already run on a schedule (nightly export, hourly rebuild):
curl -G 'https://cms.example.com/api/v1/content/article' \
-H 'Authorization: Bearer orthacms_…' \
--data-urlencode 'filter={"field":"updatedAt","op":"gte","value":"2026-09-30T00:00:00Z"}' \
--data-urlencode 'sort=updatedAt' \
-d 'pageSize=100'No public URL, no secret, no deduplication.
Subscribe when publish-to-live latency matters: a storefront cache, a site that should rebuild in a minute.
Either way the API is the source of truth. A webhook says that a record changed and which one; you read it back with your own token.
A delivery on the wire
POST /hooks/orthacms HTTP/1.1
Content-Type: application/json
User-Agent: Orthacms-Webhooks/1
X-Orthacms-Event: entry.published
X-Orthacms-Event-Id: 0f3e…
X-Orthacms-Delivery: 7b21…
X-Orthacms-Workspace: 3f7a0c2e-5b1d-4e8a-9f3c-7d2e1a6b4c90
X-Orthacms-Attempt: 1
X-Orthacms-Signature: t=1790762400,v1=5d41402abc4b2a76b9719d911017c592…{
"id": "7b21…",
"event": "entry.published",
"eventId": "0f3e…",
"occurredAt": "2026-09-30T10:00:00.000Z",
"workspaceId": "3f7a0c2e-5b1d-4e8a-9f3c-7d2e1a6b4c90",
"actor": { "id": "5b1d…", "email": "editor@example.com" },
"data": {
"kind": "content_entry",
"id": "9c4b…",
"contentType": "article",
"title": "Hello, Postgres"
}
}| Field / header | Use |
|---|---|
X-Orthacms-Event-Id / eventId | Deduplicate on this. Stable across retries and redeliveries. |
X-Orthacms-Delivery / id | This delivery. A redelivery gets a new one. |
X-Orthacms-Signature | t=<unix seconds>,v1=<hex>: HMAC-SHA256 of "{t}.{raw body}" with the endpoint secret. |
data.contentType, data.id | What to read back. |
data.fields | On entry.updated: the fields that changed. |
data.soft | On entry.deleted: true for a soft delete. |
Events: entry.created, entry.updated, entry.published,
entry.unpublished, entry.deleted, entry.restored, entry.purged, plus
ping when an administrator tests the endpoint.
The three obligations
- Verify the signature against the raw bytes. Reject a timestamp older than five minutes. Compare in constant time.
- Deduplicate on the event id. Delivery is at-least-once and unordered.
- Answer
2xxwithin the timeout (10 s default), then work. A slow handler times out, is retried, and does the work twice.
A minimal receiver
Plain node:http, no dependencies:
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';
const SECRET = process.env.ORTHACMS_WEBHOOK_SECRET; // whsec_…
const seen = new Set(); // production: a table with a unique index
function verify(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;
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > tolerance) return false;
const expected = createHmac('sha256', SECRET)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
return (
expected.length === received.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(received))
);
}
createServer((req, res) => {
if (req.method !== 'POST' || req.url !== '/hooks/orthacms') {
res.writeHead(404).end();
return;
}
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
const rawBody = Buffer.concat(chunks).toString('utf8');
if (!verify(req.headers['x-orthacms-signature'], rawBody)) {
res.writeHead(401).end(); // 4xx: not retried
return;
}
const eventId = req.headers['x-orthacms-event-id'];
if (seen.has(eventId)) {
res.writeHead(200).end(); // already handled
return;
}
seen.add(eventId);
const envelope = JSON.parse(rawBody);
res.writeHead(202).end(); // answer first…
queueMicrotask(() => handle(envelope).catch(console.error)); // …then work
});
}).listen(8080);
async function handle({ event, data, workspaceId }) {
if (event === 'ping') return;
console.log(event, data.contentType, data.id, workspaceId);
}In a Node project you can import the check instead:
import { verifySignature } from '@orthacms/webhooks-domain';
const ok = verifySignature(SECRET, req.headers['x-orthacms-signature'], rawBody);With Express, keep the raw body:
app.post('/hooks/orthacms', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8');
if (!verify(req.get('x-orthacms-signature'), rawBody)) return res.sendStatus(401);
// …dedupe, respond, queue
});Sign the raw body, not the parsed one
express.json() throws the bytes away, and JSON.stringify of the parsed
object is not guaranteed to match them. Use express.raw(), or a verify
callback on express.json() that keeps req.rawBody.
What to do with a delivery
Invalidate a cache
async function handle({ event, data }) {
if (!event.startsWith('entry.')) return;
await cache.purge([
`entry:${data.contentType}:${data.id}`,
`list:${data.contentType}`
]);
}- With tag-based revalidation, use the same strings:
revalidateTag('entry:' + data.id)in Next.js, or your CDN’s purge-by-tag. - Purge the list tag on unpublish and delete too — they change lists as much as a publish does.
Rebuild a static site
Coalesce, or twelve saves in a minute queue twelve builds:
let pending = null;
function scheduleRebuild() {
if (pending) return;
pending = setTimeout(async () => {
pending = null;
await fetch(process.env.DEPLOY_HOOK_URL, { method: 'POST' });
}, 30_000);
}
// Events that change what a published read returns.
const REBUILD_ON = new Set([
'entry.published',
'entry.unpublished',
'entry.deleted',
'entry.restored',
'entry.purged'
]);
async function handle({ event }) {
if (REBUILD_ON.has(event)) scheduleRebuild();
}entry.createdandentry.updatedare left out: on a publishable type a create is a draft, and since 0.7.0 an edit to a live entry does not change published reads until it is published. Add both if you build non-publishable types, whose rows are live on save.- More than one receiver instance? Keep the timer in something shared, e.g. one
row with a
rebuild_aftertimestamp.
Mirror into another system
Read the record through GET /api/v1/content/:type/:id and upsert it by entry
id.
- Idempotent. An upsert makes a duplicate a no-op, and out-of-order events converge because each handler reads the record as it is now.
- Handle 404. After
entry.unpublished,entry.deletedorentry.purgedthe published read is a 404. Remove it from the mirror — do not retry.
Retries and failure
- A
4xxends the delivery. A5xx,408,429or timeout is retried. Answer401to a bad signature,400to an unparseable body,503to a transient failure of your own. Retry-Afteron a408or429is honoured, up to an hour.- Six attempts over about two and a half hours: waits of ~10 s, 1 min, 5 min, 30 min and 2 h, with ±20% jitter. Then the delivery is dead.
- 20 consecutive dead deliveries switch the endpoint off. The admin’s Webhooks page says why; re-enabling resets the count.
- The secret is shown once. To rotate, put the new secret in the receiver first: it signs everything sent after the rotate, queued rows included.
- The delivery log on
/webhooks/:idin the admin keeps each frozen body and the first 2 KB of your response.
Entry events only, and no history
No media, account or workspace events. An endpoint hears only about changes after it was created — no replay. For an initial load, page through the API once, then subscribe.