Webhooks
Get an HTTP call when something changes in your world. Point an endpoint at your server, pick the events you care about, and Chronicler POSTs each one as it happens.
A webhook is a URL Chronicler calls when something changes in a workspace. Instead of polling the API for new resources, you give us an address and we POST a signed JSON body to it every time a matching event fires. Use them to mirror content into your own database, kick off a build, post to a chat channel, or keep a search index warm.
Hosted feature
Outbound webhooks run on Chronicler hosted. A self-hosted core can read the same events off its own stream, but the managed delivery, retries, and signing described here are part of the hosted build.
Set up an endpoint
Webhooks live on the workspace, not on a single chronicle. Open Workspace settings, then Webhooks, and register an endpoint. You give it three things:
- the URL we should POST to,
- the events it should receive,
- an optional chronicle filter, if you only want events from one chronicle in the workspace.
When you register the endpoint, Chronicler generates a signing secret and shows it to you once.
Copy the secret now
The signing secret is shown a single time, at creation. Store it somewhere safe before you close the dialog. If you lose it, rotate the secret to get a new one. The old one stops working the moment you rotate.
Choose your events
Each endpoint subscribes to a set of event types. You can change the set later without re-registering.
| Event | Fires when |
|---|---|
resource.created | A resource is created. |
resource.updated | A resource's name, summary, or images change. |
resource.attributes_set | A resource's attribute values change. |
resource.lore_set | A resource's lore is edited. |
resource.deleted | A resource is deleted. |
Verify the endpoint
A new endpoint starts unverified and delivers nothing. To turn it on, send it a verification ping from the endpoint's menu. We POST a small endpoint.verification body to your URL, and if your server answers with any 2xx status, the endpoint becomes verified and starts delivering.
Two things worth knowing:
- Receiving the ping is not enough. Your handler has to return a 2xx. If it logs the request but returns a 404 or a 500, verification fails and the endpoint stays off.
- Changing an endpoint's URL clears its verified state, so a re-pointed endpoint waits for a fresh ping before it delivers again.
Some installations do not require verification. On those, an endpoint is marked "skipped" and delivers straight away.
What a delivery looks like
Every delivery is a POST with a JSON body in this shape:
{
"id": "whd_6311hvq3sgvd7vsmyxtmsh0sas",
"type": "resource.updated",
"occurredAt": "2026-09-03T14:24:27Z",
"version": "2026-09-02",
"workspaceId": "wksp_6311hvq3sgvd7vsmyxtmsh0sas",
"chronicleId": "chron_6311hvq3sgvd7vsmyxtmsh0sas",
"data": {
"id": "res_6311hvq3sgvd7vsmyxtmsh0sas",
"kind": "character",
"name": "Bomanz",
"summary": "A wizard of the north."
}
}
The id is the delivery id. A retry resends the same id, so use it to dedupe on your side. chronicleId is present for chronicle-scoped events and left out for workspace-level ones. data changes per event type: resource.attributes_set carries the changed values and any cleared keys, resource.lore_set carries a segmentCount, and resource.deleted carries only the id, since the resource is already gone by the time you hear about it.
Verify the signature
Every delivery carries a Chronicler-Signature header so you can prove it came from us and was not changed in transit:
Chronicler-Signature: t=1756909467,v1=5257a869e7ecebeda32affa62cdca3fa...
t is the Unix timestamp we signed at. v1 is an HMAC-SHA256, keyed with your signing secret, over the string "{t}.{raw body}". Recompute it and compare:
import crypto from "node:crypto"
function verify(signatureHeader, rawBody, secret) {
const parts = Object.fromEntries(signatureHeader.split(",").map((p) => p.split("=")))
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex")
return crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected))
}
Sign the raw request body, before any JSON parsing reformats it. Reject the delivery if the signatures do not match, and reject a timestamp that is too old to guard against replays.
Delivery statuses
Every attempt is recorded in the endpoint's delivery log, each with a status:
| Status | What it means |
|---|---|
pending | Queued and waiting, or sitting between retries. |
delivered | Your endpoint answered with a 2xx. |
failed | Every retry was used up and the delivery was given up on. |
permanent | Your endpoint returned a 4xx other than 429, so a resend would not help. |
skipped | The endpoint was disabled, unverified, or removed when the delivery ran. |
Stuck on skipped?
A skipped delivery almost always means the endpoint was not deliverable at the time. Check that it is enabled and verified, then redeliver the row from the log.
Retries and back-off
How we treat a delivery depends on your response:
- A 2xx marks it delivered and clears the endpoint's failure count.
- A 429 or any 5xx is treated as temporary. We retry.
- A network or timeout error is also temporary. We retry.
- Any other 4xx is permanent. We stop and mark it so.
Retries follow a fixed back-off: one minute, then five minutes, thirty minutes, two hours, six hours, and twelve hours. After the last one the delivery is marked failed. You can redeliver any finished delivery by hand from the log, which resets it to pending and sends it again.
If an endpoint fails 15 deliveries in a row, Chronicler disables it so a dead URL stops piling up failures. Fix your side, then re-enable it. Re-enabling clears the failure count.
Scope: workspaces and chronicles
This is the one thing that catches people out. An endpoint belongs to a workspace, and it only receives events from chronicles that workspace owns. A chronicle filter narrows that further to a single chronicle.
Right event, wrong workspace
If you register a webhook in one workspace but edit a chronicle that belongs to another, nothing arrives, and no error tells you why. The event fired for the workspace that owns the chronicle you changed, not the one you were looking at in settings. Register the endpoint in the workspace that owns the chronicle you want events from.