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
4xxstops the retries; a5xxor a timeout continues them. So answer401to a bad signature and400to a body you cannot parse — you do not want either retried — and let a transient failure on your side be a503.429with aRetry-Afteris honoured, up to an hour. - Twenty consecutive dead deliveries switch the endpoint off. A receiver
that has been returning
404since 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/:idin 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.