Webhooks
An operator guide to Kiln's outbound webhooks: signed HTTP push on content
lifecycle events, with SSRF-safe delivery, retry/backoff, and a delivery
ledger. Split out of #53
as #570 — the feature
(lib/kiln_cms/webhooks/) had never had a guide of its own; see
Automation for the complementary in-app reactions
(email/broadcast/cache/reindex) that don't need a receiver at all.
Registering an endpoint
Admins manage endpoints at /editor/webhooks. Adding one takes a URL and a
set of events to subscribe to; Kiln generates a signing secret at creation time
(shown on the endpoint's row — it never changes, even across edits). The secret
is stored encrypted with the app's vault key, which is derived from
SECRET_KEY_BASE. Rotating that key leaves every endpoint's secret unreadable:
the row says so, and deliveries are refused rather than sent unsigned.
Re-create the endpoints after a rotation (secrets rotation). Toggling
Active or editing an endpoint gives it a clean slate: its failure count
resets (see Auto-disable below).
Deleting an endpoint takes its delivery history with it.
The form starts with the default subscription ticked, the same list an
endpoint created over code or AshAdmin gets when it names no events: the
"Subscribed by default" rows of the table below, plus form.submitted. The
events that carry unpublished content are marked includes unpublished
content and start unticked. Select all, Clear and Reset to
defaults act on the whole list, and each group (one per content type, plus
tasks, releases, experiments, memberships, and the tag, tag group and category
taxonomy) has its own toggle. An endpoint
that receives unpublished content says so on its row.
Event names & payload
An event name is <type>.<verb>. Every registered content type — built-in and
admin-defined dynamic types alike (D17) — gets one event per lifecycle verb it
can emit, so a new content type has webhook events for free:
| Verb | Fires when | Subscribed by default? |
|---|---|---|
published | A document goes live | Yes |
unpublished | A live document is taken down | Yes |
updated | A published document is edited (draft edits/autosaves stay silent) | Yes |
in_review | A document is submitted for review (#375) | No — opt in |
returned_to_draft | A review is sent back to draft | No — opt in |
created | A document is created (API, editor, or import) | No — opt in |
archived | A document is archived, from any state | Yes |
deleted | A document is moved to the trash (DELETE over the API is this too) | Yes |
restored | A document comes back from the trash, or is unarchived to draft | Yes |
A mirror needs archived, deleted and restored, or it keeps serving
documents the site no longer has. They are on by default for that reason, and
they are safe defaults because of what they carry (see the payload
shapes below): archived and deleted send a
tombstone, the document's identity and nothing else, since they fire for
drafts as readily as for live documents. restored sends the full body only
when the document is published again (it is back on the delivery path and a
mirror must re-ingest it); otherwise it sends the tombstone too.
The trash is a soft delete. A hard purge from the trash, or the nightly
auto-purge, sends nothing more: the receiver already heard deleted.
unpublished fires for both an explicit unpublish and archiving a
published document (#914) — both remove it from delivery, and a receiver
watching for content leaving delivery should not have to subscribe to two
events to hear about it. The payload's data.state reflects which happened:
"draft" for an unpublish, "archived" for an archive.
in_review, returned_to_draft and created are opt-in only. Their payload is
the full serialized body of a document that has never been published, and a
receiver built for publish-mirroring must not be sent draft or embargoed content
it didn't ask for. created fires for every create path, bulk
import included, so expect one per imported record.
More event names exist outside the <type>.<verb> pattern:
form.submitted— a public form submission (Forms); every endpoint is subscribed by default.membership.activated/membership.canceled— a paid membership started or stopped granting access (Paid memberships); opt in.task.assigned/task.overdue,release.published/release.rolled_back/release.failed, andexperiment.concluded— editorial tasks, content releases and A/B experiments; opt in.tag.created/tag.updated/tag.deleted, the same three fortag_group, and the same three forcategory— taxonomy changes (#1965); opt in. A receiver that caches taxonomy (a tag cloud, a list of payers kept as a tag group) can invalidate on them instead of polling. See Taxonomy events below.ping— a manual test delivery (see Ping below); never subscribed to, and delivered on demand regardless of an endpoint's active state or event list.
Each of these namespaces (form, membership, task, release,
experiment, tag, tag_group, category) is reserved: a new content type
cannot take one as its name, so release.published always means a release
and never a document of a type called release
(#1994). A type
created with one of these names before the rule keeps working and can still be
edited, but its events still share names with the built-in ones.
The request body is always
{"event": "<name>", "delivery_id": "<uuid>", "data": {...}}. delivery_id is
the ledger row's id: the same on every retry of one delivery, and new for a
redelivery. It is inside the signed body, so a receiver can trust it as a
dedupe key. data shape depends on the event:
- Tombstones (
archived,deleted, andrestoredfor a document that is not published) —{"id", "slug", "locale", "state", "updated_at"}.stateis the document's workflow state:"archived"after an archive, and the state it was trashed in fordeleted("published"tells you it was live). - Other content lifecycle events (
published/unpublished/updated/in_review/returned_to_draft/created, andrestoredfor a published document) — the document's public fields (id,title,slug,excerpt,blocks,seo_*,canonical_url,locale,state,audience,locked,published_at,scheduled_at,inserted_at,updated_at); each block trimmed totype,content,data,order,children. Internal-only fields (e.g. search text) are never included. These payloads also carryaffected: the published documents whose fired artifacts embed this one, each as{"type", "id", "slug", "locale"}(see below). form.submitted—{"form": "<slug>", "data": {...submitted fields...}}.membership.activated/membership.canceled— the member (user_id,email), the tier (id,slug,name,audience), the transition (status,previous_status,occurred_at) andevent_idto dedupe on; see Paid memberships.- Taxonomy events (
tag.*,tag_group.*,category.*) —{"id", "slug", "name", "updated_at"}, plustag_group_idfor a tag. Anupdatedaddsprevious_slugwhen the slug changed and, for a tag,previous_tag_group_idwhen it moved group. Adeletedis the record as it was. See Taxonomy events. ping—{"message": "KilnCMS webhook test", "endpoint_url": "...", "sent_at": "<ISO 8601>"}.
// POST to your endpoint, page.published
{
"event": "page.published",
"delivery_id": "0b9c…",
"data": {
"id": "…", "title": "Launch", "slug": "launch", "state": "published",
"audience": "public", "locked": false,
"blocks": [{ "type": "paragraph", "content": "…", "data": {}, "order": 0, "children": [] }],
"published_at": "2026-08-04T12:00:00Z", "…": "…",
"affected": [{ "type": "page", "id": "…", "slug": "about", "locale": "en" }]
}
}Taxonomy events
Creating, editing or deleting a tag, tag group or category sends one event, after the write commits, to the endpoints of that site that selected it. None is subscribed by default, so an endpoint created before these events existed receives none of them until an admin ticks them.
// tag.updated: renamed, re-slugged and moved to another group
{
"event": "tag.updated",
"delivery_id": "…",
"data": {
"id": "…", "slug": "payer-acme-health", "name": "Acme Health",
"tag_group_id": "…", "updated_at": "2026-10-15T12:00:00Z",
"previous_slug": "payer-acme", "previous_tag_group_id": "…"
}
}previous_slug/previous_tag_group_idare present only when that field changed. They come from the record the editor was working on, so treat them as a hint: if two people edited the same tag at once, one of them may report a value that was already out of date.idis what to key on.updatedfires on every save, including one that changes only a tag group's position. Useupdated_atto drop a retry that arrives after a later event. Adeletedcarries theupdated_atof the last edit, so it can tie with theupdatedbefore it: on a tie,deletedwins.- Deletes cascade without further events. Deleting a tag removes it from
every document, media item and entry that carried it, and those records get
no
updatedevent. Deleting a tag group leaves its tags ungrouped, and those tags get notag.updated. Ontag.deleted, drop the tag'sidfrom anything you cached; ontag_group.deleted, treat the group's tags as ungrouped. Deleting a category leaves its documents with no category, with no event for them either. - A taxonomy delete is not a retraction. It is delivered and retried like any other event (see Retractions are exempt, which covers documents only), and it counts toward auto-disable.
- Taxonomy events do not run automation rules or federate. They do trigger the optional CDN purge, because the cached taxonomy lists and every cached document carrying the term change.
- A content type cannot be named
tag,tag_grouporcategory, so these names always mean taxonomy. The same goes for every other namespace above (form,membership,task,release,experiment).
affected: the other pages this change touched
A document that embeds another (a formula page listing a herb, a page with a
reference block, a fragment's users) re-fires when its target changes, so its
delivered artifact changes too. A consumer that caches per page used to learn
only about the document in the event, and the pages built on it waited out
their TTL. data.affected lists those referrers, so a receiver can
invalidate or refetch them in the same breath (#1925):
- Each entry is
{"type", "id", "slug", "locale"}, withtypethe public type name exactly as event names use it ("recipe", not"entry"). - Only referrers that are currently published are listed: the list is for invalidating pages a consumer serves, and a referrer that has since been unpublished or trashed has no page to invalidate.
- It is the firing dependency graph as it stands at the moment of the event, the same edges Kiln's own re-fire wave walks. Edges are written when a referrer fires (on publish), so a draft that has never been published is never listed.
- Present on every full payload, including
unpublished, where it names the pages that embedded the retracted document. Tombstones stay identity only. - An empty list means nothing embeds the document, never that the lookup
was skipped; a failed lookup is logged and answers
[]rather than cost the delivery.
Gated content is delivered too — check audience
A webhook fires for an audience-gated document exactly as it does for a public one, and the payload carries the full block tree. That is deliberate: an endpoint is somewhere you chose to send content, the request is HMAC-signed and the URL is SSRF-guarded, so this is not the anonymously-queryable surface the Meilisearch index is (which does exclude gated content, #1006).
But it means your receiver decides, and the payload carries both halves of the decision Kiln makes for its own surfaces:
| Field | Value when open | Filter if your sink is public |
|---|---|---|
audience | "public" | anything else ("member", or whatever your deployment configures) is content a reader was supposed to pay for or be granted |
locked | false | true means a shared passphrase stands between the reader and this body (#496) |
Both, not either. Kiln's own rule is three-part — published and public
and unlocked — so a receiver checking only audience mirrors a document
that was published openly and locked afterwards, which still arrives as
"audience": "public". locked is derived; the hash and the
password_fingerprint never leave.
A narrowing updated event is a retraction. Gating or locking a live
document fires <type>.updated with the new values, so a receiver that filters
only at ingest keeps serving the body it already mirrored. Treat an updated
whose audience moved away from "public", or whose locked became true, as
an instruction to withdraw it.
Verifying the signature
Every request carries these headers:
| Header | Value |
|---|---|
x-kilncms-webhook-signature | t=<unix seconds>,v1=<hex> where v1 is the lowercase hex HMAC-SHA256 of "<t>.<raw body>", keyed by the endpoint's secret |
x-kilncms-delivery-id | The delivery's id, the same value as the body's delivery_id. Stable across retries |
x-kilncms-event | The event name (redundant with the body's event field, for routing without a parse) |
To verify a delivery:
- Split the header on
,intotand one or morev1values. - Reject it if
tis more than 300 seconds (five minutes) from your own clock, in either direction. This is what stops a captured request from being replayed later. Kiln signs each attempt when it sends it, so a retry carries a fresht. Keep your clock NTP-synced. - Compute the HMAC of the string
t, then., then the raw request body, using the exact bytes you received. Do not re-serialize the parsed JSON: key order and whitespace aren't guaranteed to round-trip. - Accept if any
v1matches, compared in constant time. - Optionally, remember each
delivery_idfor the length of your window and drop a repeat. That makes delivery exactly-once inside the window as well as outside it.
The official clients do all of this: verifyWebhook in the
JS client and KilnClient.Webhook.verify/4 in the
Elixir client. By hand:
// Node
const crypto = require("crypto");
function verify(secret, rawBody, header, toleranceSeconds = 300) {
const pairs = header.split(",").map((p) => p.trim().split(/=(.*)/s));
const ts = pairs.filter(([k]) => k === "t").map(([, v]) => v);
if (ts.length !== 1 || !/^\d+$/.test(ts[0])) return false;
if (Math.abs(Date.now() / 1000 - Number(ts[0])) > toleranceSeconds) return false;
const expected = Buffer.from(
crypto.createHmac("sha256", secret).update(`${ts[0]}.`).update(rawBody).digest("hex"),
);
return pairs.some(([k, v]) => {
if (k !== "v1") return false;
const given = Buffer.from(v);
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}# Python
import hmac, hashlib, time
def verify(secret: str, raw_body: bytes, header: str, tolerance: int = 300) -> bool:
pairs = [p.split("=", 1) for p in header.split(",") if "=" in p]
ts = [v for k, v in pairs if k == "t"]
if len(ts) != 1 or not ts[0].isdigit() or abs(time.time() - int(ts[0])) > tolerance:
return False
expected = hmac.new(secret.encode(), ts[0].encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, v) for k, v in pairs if k == "v1")The removed x-kilncms-signature. Releases 0.10 and 0.11 also sent a
body-only x-kilncms-signature: the hex HMAC of the body alone. It proved a
delivery came from Kiln unmodified, but not when it was sent, so a captured
request could be replayed indefinitely to a receiver that checked only that
header. It was deprecated in 0.10.0 and is no longer sent from 0.12.0 (#1616).
A receiver still reading it sees the header missing on every delivery; switch
it to x-kilncms-webhook-signature as above.
An admin redelivery is a new delivery: a new delivery_id and a fresh t.
It is meant to be accepted.
Egress protections (SSRF)
Endpoint URLs are validated on every create/edit, and re-resolved (not just
re-checked) on every delivery attempt, by KilnCMS.Webhooks.SafeUrl:
- Blocked hosts:
localhost,metadata.google.internal,metadata.goog, and any.local/.internalhostname. - Blocked addresses: loopback (
127.0.0.0/8,::1), unspecified (0.0.0.0,::), link-local (169.254.0.0/16,fe80::/10), private ranges (10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7ULA), carrier-grade NAT (100.64.0.0/10), and the broadcast address — checked against IPv4-mapped/-compatible IPv6 addresses too, so::ffff:10.0.0.1doesn't slip through. - DNS resolution is checked, then pinned. A hostname is resolved once
before delivery, every answer is checked against the same blocklist, and the
HTTP client connects to that resolved address (not the hostname) with the
original
Hostheader and TLS SNI restored separately. Re-resolving at connect time would reopen a DNS-rebinding window — an attacker's DNS could answer safely during validation and privately at connect time — so the pinned address is what the request actually dials. Resolution has a 3-second timeout so a stalled/firewalled name can't hang a delivery worker. - Redirects are not followed. A 3xx response is treated as the delivery outcome (and retried like any other non-2xx), not a new destination — an endpoint can't hop to a blocked address after passing validation.
- HTTPS is required in production (
require_https: true); HTTP is accepted in dev/test.
Delivery, retries, and failure handling
A publish/unpublish/update/form-submit enqueues one Oban job per active,
subscribed endpoint, plus one ledger row (WebhookDelivery) per job — the row
is updated on every attempt, not just the outcome. The row's id is the
delivery_id the receiver sees.
- Delivery is a
POSTwith a 5-second connect timeout and a 15-second response timeout. - A non-2xx response or a transport error fails the job, and Oban retries with exponential backoff up to 5 attempts. Retractions get a longer schedule (see Retractions are exempt).
- Once retries are exhausted, the delivery is marked
:failed— permanently; nothing retries it further unless an admin redelivers it. - A delivery whose endpoint secret can no longer be decrypted (see
Registering an endpoint) fails with
delivery failed: signing secret unreadableand is never sent.
Auto-disable
Every exhausted delivery counts against the endpoint's consecutive_failures.
After 10 exhausted deliveries in a row, the endpoint is auto-disabled
(auto_disabled_at stamped, shown in the UI) and stops receiving new
deliveries — a dead receiver doesn't burn the delivery queue forever. Any
successful delivery to an active endpoint, or any admin edit, resets the
counter to zero. auto_disabled_at is cleared only when the endpoint is
active again: a ping or a retraction that gets through to an auto-disabled
endpoint leaves it auto-disabled, and so does an edit that leaves Active
unticked (fixing the URL before you ping it, say). Tick Active to turn it
back on. To stop an auto-disabled endpoint receiving retractions, delete it.
Retractions are exempt
Three verbs take a document off a consumer: <type>.unpublished,
<type>.archived and <type>.deleted. When the document was published
before the action, a consumer that misses one keeps serving something the
site has withdrawn, so these are exempt from auto-disable. (archived and
deleted also fire for drafts. A draft was never delivered, so its tombstone
is an ordinary delivery.)
- They still go to an auto-disabled endpoint. An endpoint that has been
failing
updateddeliveries for an hour and was switched off by the pipeline still receives theunpublishedthat follows. An endpoint an admin switched off (unticked Active) receives nothing; that was a decision, not a fault. - They retry longer: 30 attempts, starting 30 seconds apart and
doubling up to one hour apart, which comes to about a day. An endpoint that
has been auto-disabled for more than 7 days
(
retraction_stale_after_days) gets one attempt, so a long-dead receiver cannot hold the queue for a day per retraction. - They never count against
consecutive_failures. A failing retraction cannot be what disables an endpoint, and one that gets through to an auto-disabled endpoint does not re-enable it. - When one exhausts its retries, an editor is told:
- every admin of the site gets a "Retraction not delivered" notice in
the console bell and
/editor/inbox. It names the document, the endpoint and the last error. It cannot be muted. It is not sent for an endpoint past the 7-day stale window, whose losses were announced while it was fresh; those still show on the trail. - the document's governance trail (
/editor/governance/:type/:id, which the notice links to) shows a Retractions not delivered panel. The panel lists each failed delivery with its endpoint, attempt count and error. Once any laterunpublished,archivedordeletedof the same document reaches the same endpoint (a redelivery, or a later verb), the row shows when. A trashed document still has a trail for this reason. The JSON and CSV trail exports carry the same rows. - a failed retraction is kept past the 30-day ledger retention, and
through the deletion of its endpoint, so the trail does not lose it.
Past the retention window its payload is cut down to the tombstone's
identity fields (
id,slug,locale,state,updated_at), so a withdrawn body is not kept indefinitely; a redelivery after that sends the identity only.
- every admin of the site gets a "Retraction not delivered" notice in
the console bell and
Fix the receiver, then press Redeliver on the row in the trail's panel.
/editor/webhooks lists only the 25 newest deliveries, and a retraction is
given up on about a day after it was sent.
Ping and redeliver
- Ping sends a one-off
"ping"test event to a single endpoint — it delivers even while the endpoint is inactive or auto-disabled, so you can verify a receiver before flipping it on (or diagnose one that's already off). - Redeliver replays a recorded delivery — same endpoint, event, and payload — as a new ledger row. History is immutable; a redelivery never overwrites the original failed attempt.
Delivery history
/editor/webhooks shows the 25 most recent deliveries across all of an
org's endpoints: timestamp, event, target endpoint, status
(delivered/retrying/failed), attempt count, last HTTP status, and last error.
Delivery rows are retained for 30 days, then pruned by a nightly job (per
org, so one site's retention sweep never touches another's history). A failed
retraction is the exception: it is kept, because it is on the document's
governance trail.
Each row is one delivery to one endpoint, and delivered_at is when that
endpoint answered 2xx. For an unpublished, the latest delivered_at across
the endpoints is when the last consumer heard about the withdrawal.
Configuration
# Days an endpoint may sit auto-disabled before a retraction to it gets one
# attempt and no console notice (default 7)
config :kiln_cms, KilnCMS.Webhooks, retraction_stale_after_days: 7
# Exhausted deliveries in a row before auto-disable (default 10)
config :kiln_cms, KilnCMS.Webhooks, auto_disable_after: 10
# Delivery ledger retention, in days (default 30)
config :kiln_cms, :webhooks, delivery_retention_days: 30
# SSRF guard (see config/prod.exs for the production defaults)
config :kiln_cms, KilnCMS.Webhooks.SafeUrl,
require_https: true,
resolve_dns: trueMulti-tenancy
Endpoints and deliveries belong to one organization (epic #336): a publish only fans out to its own site's subscribed endpoints, and the admin panel only ever shows the current org's endpoints and history.