Features Apograph CMS on GitHub

Reacting to changes

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

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

Documents 0.5.2 Updated Edit this page Report a problem

On this page

The public API is pull. The webhooks plugin is the push half: an administrator registers your URL, and the CMS POSTs a signed envelope whenever a matching entry changes. This page is the receiving end — what to build, what to check, and what to do once a delivery lands.

Poll or subscribe?

Both are legitimate, and the choice is about the shape of the consumer rather than about which is more modern.

Poll when the consumer already runs on a schedule and freshness within that schedule is fine: a nightly export, a static site rebuilt every hour, a search index refreshed with the rest of the batch. GET /api/v1/content/:type with a filter on updatedAt and the last run’s timestamp is the whole integration, and it has no moving parts on your side — no public URL, no secret, no deduplication.

Subscribe when the gap between an editor pressing Publish and the world seeing it is the thing that matters: a storefront’s cache, a documentation site that should rebuild in a minute rather than an hour, a downstream system that wants to be told rather than to ask. A subscription costs you a receiver, and the receiver has three obligations.

Whichever you choose, the source of truth is the API. A webhook says that a record changed and which one; it never carries the record, so every consumer ends up reading through /api/v1 with its own token either way.

The three obligations

Verify the signature. Anyone who knows your URL can POST to it. The X-Apograph-Signature header is HMAC-SHA256 over "{timestamp}.{raw body}" under the endpoint’s secret; check it against the raw bytes, reject a timestamp older than five minutes, compare in constant time.

Deduplicate on X-Apograph-Event-Id. Delivery is at-least-once and unordered. A retry, a redelivery from the admin, a worker that crashed after sending and before recording — all produce a second POST with the same event id and a different delivery id. Remember the event ids you have handled and treat a repeat as done.

Answer 2xx within the timeout, then do the work. The sender waits ten seconds by default. A receiver that rebuilds a site inside the request handler times out, is retried, and rebuilds again on the retry. Record the delivery, respond, and process it off the request.

A minimal receiver

Plain node:http, no dependencies. It reads the raw body, verifies, checks the event id against a set, answers 202, and queues the work.

import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';

const SECRET = process.env.APOGRAPH_WEBHOOK_SECRET; // whsec_…
const seen = new Set(); // use a table with a unique index in production

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/apograph') {
        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-apograph-signature'], rawBody)) {
            // A 4xx is fatal on the sender's side: it will not retry a
            // delivery you refused, which is exactly right for a bad signature.
            res.writeHead(401).end();
            return;
        }

        const eventId = req.headers['x-apograph-event-id'];
        if (seen.has(eventId)) {
            res.writeHead(200).end(); // already handled — say so, do nothing
            return;
        }
        seen.add(eventId);

        const envelope = JSON.parse(rawBody);
        res.writeHead(202).end(); // answer first

        // …then work. Never await this inside the handler.
        queueMicrotask(() => handle(envelope).catch(console.error));
    });
}).listen(8080);

async function handle({ event, data, workspaceId }) {
    if (event === 'ping') return;
    // data.kind is 'content_entry'; data.id, data.contentType and a title
    // snapshot are what you have. Read the record back for anything else.
    console.log(event, data.contentType, data.id, workspaceId);
}

queueMicrotask is the smallest honest stand-in for “off the request”. In anything real, handle writes a job to whatever queue you already run — or, for the patterns below, is short enough that the distinction stops mattering.

The same verification is exported by @apograph/webhooks-domain as verifySignature(secret, header, rawBody) if the receiver is a Node project that would rather import it than carry it.

Read the raw body, not the parsed one

Express’s express.json() and its cousins hand you an object and throw the bytes away, and JSON.stringify of that object is not guaranteed to reproduce them. Capture the raw body — express.raw({ type: 'application/json' }), or a verify callback on express.json() that stashes req.rawBody — and sign that.

What to do with a delivery

Three patterns cover almost every consumer, in increasing order of what they ask of you.

Invalidate a cache

The event names a content type and an id. That is enough to purge a cache keyed either way, and the record is re-read on the next miss through the API — which is how the read stays behind the visibility and audience rules.

async function handle({ event, data }) {
    if (!event.startsWith('entry.')) return;
    await cache.purge([
        `entry:${data.contentType}:${data.id}`,
        `list:${data.contentType}`
    ]);
}

With a framework that has tag-based revalidation, the tags are the same two strings — revalidateTag('entry:' + data.id) in Next.js, or the equivalent purge-by-tag call on a CDN. Purge the list tag on every entry event, not only on publish: an entry.unpublished or entry.deleted changes what a list returns just as much as a publish does.

Rebuild a static site

The obvious version — call the deploy hook on every delivery — works, and then an editor saves twelve times in a minute and the build queue is twelve deep. Coalesce instead:

let pending = null;

function scheduleRebuild() {
    if (pending) return;
    pending = setTimeout(async () => {
        pending = null;
        await fetch(process.env.DEPLOY_HOOK_URL, { method: 'POST' });
    }, 30_000);
}

async function handle({ event }) {
    // Only the events that change what a build would produce.
    if (
        event === 'entry.published' ||
        event === 'entry.unpublished' ||
        event === 'entry.deleted' ||
        event === 'entry.purged'
    ) {
        scheduleRebuild();
    }
}

Thirty seconds of quiet, then one build, whatever arrived in the window. Skip entry.updated unless you build from drafts — a public build reads published content, and a draft edit does not change it. If the receiver runs as more than one instance, the timer belongs in something shared; a single row with a rebuild_after timestamp does it.

Mirror into another system

For a search index, a translation vendor, a warehouse: read the record through GET /api/v1/content/:type/:id and write it across. Two things to get right.

Do it per event id, idempotently. An upsert keyed on the entry id makes a duplicate delivery a no-op, and an out-of-order pair — entry.updated arriving after entry.published — converges on the same state, because each handler reads the record as it is now rather than trusting the event’s ordering.

Handle absence. On entry.deleted, entry.purged and entry.unpublished the public read answers 404, because the record is no longer published. That is the signal to remove it from the mirror, not an error to retry.

Behaviour worth knowing

  • Filter at the endpoint, not in the receiver, where you can. An endpoint that names one content type and two event kinds is cheaper for both sides than one that takes everything and discards most of it.
  • A 4xx stops the retries; a 5xx or a timeout continues them. So answer 401 to a bad signature and 400 to a body you cannot parse — you do not want either retried — and let a transient failure on your side be a 503. 429 with a Retry-After is honoured, up to an hour.
  • Twenty consecutive dead deliveries switch the endpoint off. A receiver that has been returning 404 since a redeploy stops being sent to, and the admin’s Webhooks page says why. Re-enabling clears the counter.
  • The secret is shown once. If it is lost, rotate it — and put the new one in the receiver before rotating, because the new secret signs everything sent after the rotate, queued rows included.
  • The delivery log is on your side too. Every delivery’s frozen body and the first two kilobytes of your response are on /webhooks/:id in the admin, which is usually faster than adding logging to the receiver.

Entry events only, and no history

No media, account or workspace events fire a webhook, and an endpoint hears only about changes after it was created — there is no replay. A consumer that needs an initial load pages through the API once, then subscribes.