Webhooks
A webhook posts a small signed JSON message to a URL you own whenever content, a schema, a file or an environment changes in a project.
Why a caching site needs to be told
A site that caches what it read from Quillet needs to know when to forget it. Integrating a site compares the three ways a site can find out, and says when a webhook is the right one. This page covers how webhooks work in detail. It describes what is sent, how to tell that Quillet sent it, and what happens when your endpoint is down, and it ends with a receiver you can run.
What Quillet sends and how it retries
Subscriptions
A webhook has a name, a URL, the events it wants, and either every environment or a list of them. Each webhook gets its own secret, shown once when the webhook is created. Webhooks belong to the project's owners. A person with the owner role creates them, in the dashboard or with a personal token, and every API key is refused. A project holds at most twenty.
The URL must be https://. It must carry no user name or password, and it must not resolve to a private, loopback or link-local address. An instance can allow http:// with WEBHOOK_ALLOW_HTTP and private addresses with WEBHOOK_ALLOW_PRIVATE_NETWORKS, and a receiver on your own machine needs both.
Events
| Event | env |
data |
|---|---|---|
content.published |
its key | { items: [{ key, locale, version }] }, one delivery per publish batch |
content.unpublished |
its key | { items: [{ key, locale, version }] }, the version that was live |
content.deleted |
its key | { items: [{ key, locale, wasPublished }] } |
schema.published |
its key | { items: [{ name, version }] }; version is null when unpublished |
asset.created |
null |
{ asset: { id, mime, size } } |
asset.updated |
null |
{ asset: { id }, changed: [...] }, any of name, alt, visibility |
asset.deleted |
null |
{ asset: { id } } |
environment.created |
its key | { environment: { key, name } } |
environment.deleted |
its key | { environment: { key } }, not delivered yet |
ping |
null |
{ subscription: { id, name }, message }, the test event only |
Files are project-wide, so asset.* events have no environment. They reach a webhook whatever environments it lists.
The envelope
Every delivery is one JSON object, sent with Content-Type: application/json; charset=utf-8:
{
"id": "01J7QZ3M8T5V9X2K4N6P8R0S2T",
"type": "content.published",
"project": "acme",
"env": "main",
"occurredAt": "2026-09-12T09:14:05.000Z",
"attempt": 1,
"data": { "items": [{ "key": "landing/hero", "locale": "en-US", "version": 7 }] }
}
id is the delivery's id. It stays the same on every attempt, so dedupe on it. attempt counts from 1, and it is the only field that changes between attempts. The envelope never carries a body, a title or a setting, so read the new content through delivery. The headers repeat id as Quillet-Delivery and type as Quillet-Event, and they add Quillet-Signature.
The signature
Quillet-Signature reads t=<unix seconds>,v1=<hex>. t is the time this attempt was signed. v1 is the HMAC-SHA256, in lower-case hex, of <t>. followed by the raw body. The key is the webhook's secret, used as the exact string you were shown. For 24 hours after you rotate the secret, the header carries two v1 entries, with the new secret's entry first. Accept the request if any one of them matches, and refuse a t that is more than five minutes away from your own clock.
Verify the exact bytes you received, before you parse them. A body that has been parsed and serialised again is not the same bytes.
Retries and pausing
An answer in the 2xx range counts as a success. Anything else is a failure, and that includes a redirect, because a 3xx is never followed. A failed attempt is tried again 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours later. That makes six attempts in all, after which the delivery is marked failed. After twenty failed attempts in a row, counted across all of a webhook's deliveries, the webhook pauses itself and cancels whatever was still waiting. The test event never counts toward those twenty.
The deliveries log
Every delivery is a row in the log. The row shows its event, its status (pending, delivering, delivered or failed), its attempts, the last status code and an error sentence. When you open a row on its own, it also holds the payload, the time, code and duration of every attempt, and the first 4 KB of the answer. The log keeps thirty days. Redelivering sends the same event again as a new row, with a new id and a redeliveredFrom that points at the old row.
How to
Dashboard
- Open Settings, then Webhooks, and press New webhook.
- Give it a Name and a URL, tick the Events, and either keep All environments or choose some.
- Press Create webhook. Copy the secret now. If you are writing your own receiver, copy the snippet under How to verify a delivery as well.
- In the webhook's Actions menu, choose Send test event. The banner says what your endpoint answered and how long it took.
- Choose View deliveries to see the log. Open a row to see its payload, attempts and response, and press Redeliver to send it again.
- A paused webhook shows the reason it paused, with Resume beside it. Resume it, then redeliver what it missed.
API
The URL below is a receiver on your own machine. A default instance refuses that with 422, so first set WEBHOOK_ALLOW_HTTP=1 and WEBHOOK_ALLOW_PRIVATE_NETWORKS=1 on the CMS. Create the webhook with an owner's personal token. The answer carries the secret, and only this once:
curl -s -u '<tokenId>:<secret>' -X POST \
-H 'Content-Type: application/json' \
-d '{"name":"Local receiver","url":"http://host.docker.internal:4000/","events":["content.published","content.unpublished","content.deleted"]}' \
'http://localhost:3000/api/v1/projects/<project>/webhooks'Send the test event. The answer is the finished delivery: either "status":"delivered" with lastCode, or failed with lastError:
curl -s -u '<tokenId>:<secret>' -X POST \
'http://localhost:3000/api/v1/projects/<project>/webhooks/<webhookId>/test'Read the log, newest first, and then one delivery in full:
curl -s -u '<tokenId>:<secret>' \
'http://localhost:3000/api/v1/projects/<project>/webhooks/<webhookId>/deliveries?limit=10'curl -s -u '<tokenId>:<secret>' \
'http://localhost:3000/api/v1/projects/<project>/webhooks/<webhookId>/deliveries/<deliveryId>'Send a delivery again, and resume a paused webhook:
curl -s -u '<tokenId>:<secret>' -X POST \
'http://localhost:3000/api/v1/projects/<project>/webhooks/<webhookId>/deliveries/<deliveryId>/redeliver'curl -s -u '<tokenId>:<secret>' -X PATCH \
-H 'Content-Type: application/json' -d '{"active":true}' \
'http://localhost:3000/api/v1/projects/<project>/webhooks/<webhookId>'POST …/<webhookId>/rotate-secret answers with a new secret, and the old secret keeps signing beside it for 24 hours.
A receiver that verifies
This receiver uses nothing but Node. It verifies every request and answers 401 to any request that fails. It logs each delivery once and answers 204. Save it as receiver.mjs:
import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';
const secret = process.env.QUILLET_WEBHOOK_SECRET;
if (!secret) throw new Error('Set QUILLET_WEBHOOK_SECRET to the webhook secret.');
const seen = new Set();
export function verifyQuilletSignature(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=', 2)));
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
return header
.split(',')
.filter((p) => p.startsWith('v1='))
.some((p) => {
const given = Buffer.from(p.slice(3), 'hex');
return given.length === expected.length && timingSafeEqual(given, expected);
});
}
export const server = createServer(async (req, res) => {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const raw = Buffer.concat(chunks).toString('utf8');
if (!verifyQuilletSignature(raw, req.headers['quillet-signature'] ?? '', secret)) {
res.writeHead(401).end('Invalid signature.');
return;
}
const event = JSON.parse(raw);
if (!seen.has(event.id)) {
seen.add(event.id);
console.log(event.type, event.env, JSON.stringify(event.data));
}
res.writeHead(204).end();
});
server.listen(Number(process.env.PORT ?? 4000));
Run it with the webhook's secret. It listens on port 4000, or on PORT if you set it:
QUILLET_WEBHOOK_SECRET='<webhookSecret>' node receiver.mjs
Send a test event, then publish a document. The receiver prints ping, then content.published with the keys. A CMS running in Docker Desktop reaches your machine as host.docker.internal, which is a private address, so for this the instance needs WEBHOOK_ALLOW_HTTP=1 and WEBHOOK_ALLOW_PRIVATE_NETWORKS=1. Keep both at 0 in production. A real receiver keeps the ids it has seen somewhere that survives a restart, and turns each event into a cache purge, as Next.js shows.
Limits
- Quillet sends webhooks. It does not receive any.
- You cannot template the envelope. It is the same small object for everyone.
- Deliveries go over HTTP(S) only. There are no queues and no email.
- The SDK has no helper to verify a signature. Use the function above, which is the same snippet the dashboard shows.
- The CLI cannot create or sync webhooks, and there is no
quillet push --webhooks. - You can choose
environment.deleted, and the dashboard marks it Not sent yet. Its delivery is cancelled withCancelled: the environment was deleted.before it is ever sent. - Delivery happens at least once and in no set order. Dedupe on
id, and compareversionwhen order matters. - Each webhook receives at most 60 deliveries a minute by default, and the rest wait their turn. The test event is limited to ten a minute. The timeout and the rate are on Configuration.
Related
- Integrating a site: when a webhook is the right way for a site to learn of a change.
- Next.js: revalidating cache tags from a webhook.
- Management API: the other routes under
/api/v1. - Authentication: why a webhook needs a person, not a key.
- Configuration: the webhook variables.