Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

Reacting to changes

Webhooks plugin @orthacms/webhooks-server@orthacms/webhooks-admin

A receiver that verifies, deduplicates and answers fast — then invalidates a cache or rebuilds a site.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

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 / headerUse
X-Orthacms-Event-Id / eventIdDeduplicate on this. Stable across retries and redeliveries.
X-Orthacms-Delivery / idThis delivery. A redelivery gets a new one.
X-Orthacms-Signaturet=<unix seconds>,v1=<hex>: HMAC-SHA256 of "{t}.{raw body}" with the endpoint secret.
data.contentType, data.idWhat to read back.
data.fieldsOn entry.updated: the fields that changed.
data.softOn 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

  1. Verify the signature against the raw bytes. Reject a timestamp older than five minutes. Compare in constant time.
  2. Deduplicate on the event id. Delivery is at-least-once and unordered.
  3. Answer 2xx within 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.created and entry.updated are 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_after timestamp.

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.deleted or entry.purged the published read is a 404. Remove it from the mirror — do not retry.

Retries and failure

  • A 4xx ends the delivery. A 5xx, 408, 429 or timeout is retried. Answer 401 to a bad signature, 400 to an unparseable body, 503 to a transient failure of your own.
  • Retry-After on a 408 or 429 is 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/:id in 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.