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

Webhooks plugin

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

Telling other systems that an entry changed, without ever letting one of them stall the CMS.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

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-admin

Both 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 1918

The three numbers must be positive — 0 fails the boot. To use a 0 option, set it in config/webhooks.ts directly.

All plugin options:

OptionDefaultEffect
deliveryIntervalMs2000Worker poll interval. 0 = this process queues but never sends
batchSize20Deliveries one tick claims. Must be > 0
timeoutMs10000Per-request timeout
maxAttempts6Attempts before a delivery is dead
retentionDays30Log retention. 0 = keep forever
autoDisableAfter20Consecutive dead deliveries that disable an endpoint
allowInsecureUrlsfalsePermit http://
allowPrivateNetworksfalsePermit private destinations
claimTimeoutMs30000When a stuck delivering row is reclaimed. Must exceed timeoutMs
responseSnippetBytes2048Response 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

KindFiltered by content typeHas a workspace
entry.created, entry.updated, entry.published, entry.unpublishedyesyes
entry.deleted, entry.restored, entry.purgedyesyes
ping — the Send test button; never queued, never counts as a failurenono

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_…" }
FieldMeaning
name, urlRequired
eventKindsEmpty or omitted = every kind, including future ones
contentTypesEmpty or omitted = every type
workspaceIdsThe workspaces it listens to
allWorkspacestrue = every workspace. Default false — must be chosen explicitly
enabledDefault true
headersCustom 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 / fieldUse it for
X-Orthacms-Event / eventRouting without parsing the body
X-Orthacms-Delivery / idThis delivery. A redelivery gets a new one
X-Orthacms-Event-Id / eventIdDeduplication — stable across retries and redeliveries
X-Orthacms-Workspace / workspaceIdOmitted / null when the event has none
X-Orthacms-Attempt1-based attempt number
occurredAtWhen it happened, not when it was sent
actornull for API-token and system writes
data.titleA 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.

Example language
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 attemptWait (±20 % jitter)
110 s
21 min
35 min
430 min
52 h
6dead
ResponseResult
2xxsucceeded
408, 429Retry. Retry-After honoured, capped at 1 hour
Other 4xxdead immediately
3xxNot followed; retried as a failure
5xx, timeout, connection or DNS failureRetry
  • 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, see nextAttemptAt) | 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, unless allowInsecureUrls.
  • 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.

RouteNeeds
GET /api/webhooks, GET /api/webhooks/:idwebhooks:read
POST /api/webhooks, PATCH/DELETE /api/webhooks/:id — PATCH takes the full body, like createwebhooks: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/:deliveryIdwebhooks:read
POST /api/webhooks/:id/deliveries/:deliveryId/redeliver — new delivery id, same event idwebhooks:manage
GET /api/webhook-eventswebhooks: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.updated after entry.published. Deduplicate on X-Orthacms-Event-Id and treat every delivery as “go and look”.
  • Entry events and ping only. 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: 0 on nodes that should only enqueue.

See reacting to changes for cache invalidation and rebuild patterns.