Documentation for v2.9.0. Read the latest, v2.12.0.
Operator
Webhooks
A webhook is a signed POST Sanshain
sends to a URL for every fact it records and every provide it rejects. This chapter is both sides:
what an administrator registers and allows, and what a receiver gets and how it checks the
signature.
What changed in 2.6.0
Webhooks are new in 2.6.0 (ADR-0009). A fresh install sends nothing until an administrator allows a receiver host; an upgraded
install is in the same state. Four variables configure the worker:
WEBHOOKS_ENABLED, WEBHOOK_WORKER_INTERVAL_SECS,
WEBHOOK_DELIVERY_TIMEOUT_SECS, PUBLIC_BASE_URL. The allow-list binds
Producer and Consumer maintainers, not administrators: a hook registered by a
manage_settings holder is exempt from it at registration and at every delivery.
Hooks in three scopes#
-
Global hooks (admin page, Webhooks section;
/admin/webhooks…,manage_settings) receive every event, narrowed by an optional event-type set and an optional Producer-name glob (payments-*,*and?only). -
A Producer's hooks (its page, Webhooks card;
/admin/producers/{name}/webhooks…) receive that Producer's events. -
A Consumer's hooks (its page;
/admin/consumers/{name}/webhooks…) receive the events of every Producer the Consumer currently requires, in the dev or the trunk graph. The hook follows the require graph, so a new dependency needs no hook change.
System hooks are managed by anyone with manage_producers and by the
maintainers of the Producer; for a Consumer, the
maintainers of the Producer of the same name (the "same system" rule). The card is only shown to
callers who may manage it. A hook on an unknown Producer or Consumer answers 404
Producer 'x' is unknown.
Registering (POST on the scope's collection) takes the receiver URL, the body
format (json, default, slack or teams), the
event_types (none means every event) and optionally a producer_glob (at
most 200 characters; useful on global hooks). The answer carries the signing secret
once, with a copy button in the UI; it is stored for signing and never readable
again, the listing shows a short hint of it. Per hook:
| Action | Route (global shown; the Producer and Consumer scopes are the same under their prefix) |
|---|---|
| List, read | GET /admin/webhooks, GET /admin/webhooks/{id} |
| Pause, resume, change filters |
PATCH /admin/webhooks/{id} with paused,
event_types and/or producer_glob (null clears it). A
paused hook's deliveries stay pending and go out on resume.
|
| Rotate secret |
POST /admin/webhooks/{id}/rotate-secret: a new secret, shown once; every later
delivery is signed with it.
|
| Send test |
POST /admin/webhooks/{id}/test queues a webhook.test event for
that hook alone, delivered like any other, so the receiver, the secret and the allow-list
are all exercised. Subscribing to webhook.test is refused.
|
| Delivery log, redeliver |
GET /admin/webhooks/{id}/deliveries?limit=50 (1 to 200),
POST /admin/webhooks/{id}/deliveries/{delivery_id}/redeliver.
|
| Delete | DELETE /admin/webhooks/{id}: the hook and its delivery log. |
Creation, deletion, pause, resume, rotation and redelivery are audit entries
(WEBHOOK_CREATED, WEBHOOK_DELETED, WEBHOOK_PAUSED,
WEBHOOK_RESUMED, WEBHOOK_SECRET_ROTATED,
WEBHOOK_REDELIVERED, type ADMIN, with the system's name in
service for system hooks).
The allow-list#
Sanshain runs inside the cluster, and a hook URL is caller-supplied: a POST from
inside reaches the Kubernetes API, the metadata service and every internal endpoint a maintainer
could not otherwise touch. So a hook can only point at a host the administrator approved:
Allowed receiver hosts on the admin page, one per line, setting
webhook_allowed_hosts (GET/POST /admin/settings/webhook-allowed-hosts, manage_settings, audited as SET_WEBHOOK_ALLOWED_HOSTS).
-
A pattern is a host name with
*and?wildcards (hooks.slack.com,*.webhook.office.com) and admitshttps://to matching hosts. Prefixed withhttp://(http://n8n.corp.internal) it also admits plain HTTP to that host, for in-cluster receivers. Ports are not part of the match; patterns are lower-cased. Anything else is refused:'…' is not a host pattern: use a host name with optional `*`/`?` wildcards, prefixed `http://` only to allow plain HTTP to it. -
A URL outside the list is refused at registration with
403the host 'x' is not in the webhook allow-list — ask an administrator to allow it, or use one of: hooks.slack.com, …(with(plain http:// needs a pattern that says http:// itself)for anhttp://URL). -
With an empty list only administrators may register: a maintainer gets
403no hosts are allowed for webhooks yet — an administrator has to add the receiver's host to the allow-list (admin page, Webhooks) first. A fresh install is therefore safe before anyone has thought about it. -
Hooks registered by a caller with
manage_settingsare exempt, at registration and at every delivery, for as long as they exist; the listing marks them exempt. The list therefore binds maintainers, not administrators, who could change it anyway. -
The list is evaluated again at every delivery: removing a host stops deliveries to it with a
failed attempt and the reason
the hook's host is not in the allow-list (webhook_allowed_hosts) any more; the attempts continue on the backoff schedule until the host is allowed again or the 24-hour window closes.
Redirects are never followed, so a receiver cannot walk the signed body to another host.
Events#
Every event is a graph fact or a rejection; there are no admin or audit events, the audit log is
their record. Dry runs emit nothing. A global hook narrowed to a Producer glob never receives
branch.cut, which is about no Producer.
| Type | When | data |
|---|---|---|
version.published |
A provide succeeded: a new version, an overwritten snapshot or a promotion. The idempotent no-op emits nothing. |
producer, api_type (openapi, asyncapi,
proto), version, stability (snapshot,
ga), promoted (bool), stream (dev,
trunk or a branch name), actor (username or null),
compatibility (null, or
{"against": "<previous GA>", "impact": "none|patch|minor|major"} on a GA
provide with a previous GA), origin (build_url,
change_url, tickets, note, each absent when the build
said nothing).
|
provide.rejected |
A provide was refused by a version rule. |
producer, api_type, version (the attempted one),
rule (immutable_ga, snapshot_on_ga,
breaking_without_major, message_contract_conflict,
ga_requires_releaser), message, proposed_version (or
null), actor.
|
version.deprecated |
A provide marked an element deprecated that was not deprecated in the previous GA (every deprecated element when there is no previous GA). |
producer, api_type, version, stability,
elements (list of {"path","method"}),
document_deprecated (true when every element is deprecated).
|
dependency.recorded |
A require recorded an edge the Consumer did not hold yet: Consumer, Producer, endpoint, pinned version, stream. Re-recording an existing edge emits nothing. |
consumer, producer, api_type, version,
stability, snapshot_pinned (bool), stream,
endpoints (the endpoints new to the edge, {"path","method"}).
|
branch.cut |
A sanshain-branch was created. Global hooks only. |
name, source (trunk or the source branch),
as_of, cut_at, actor.
|
catalog.recorded |
A catalog push ran, whether or not Sanshain could read the file. Delivered to the system's hooks (as a Producer and as a Consumer) and to global hooks. |
system, trunk (bool), recorded (bool),
warning (the reason when recorded is false, else
null), resources (peripheral resources recorded).
|
webhook.test |
The hook's Send test. Sent to that hook alone; cannot be subscribed to. | hook_id, message. |
The envelope#
Every delivery carries the same envelope; the json format sends it as the body:
{
"id": "6f1c0c7e-8d3a-4b86-9e8e-2f3c0a1d5b77",
"type": "version.published",
"occurred_at": "2026-10-17T09:12:44Z",
"sanshain_version": "2.6.0",
"data": {
"producer": "orders",
"api_type": "openapi",
"version": "2.0.0",
"stability": "ga",
"promoted": false,
"stream": "trunk",
"actor": "alice",
"compatibility": { "against": "1.4.0", "impact": "major" },
"origin": {
"build_url": "https://ci.acme.corp/job/orders/42",
"change_url": "https://github.com/acme/orders/pull/7",
"tickets": ["ORD-7"],
"note": "Ship the orders API v2"
}
},
"links": {
"producer": "https://sanshain.corp/producers.html?service=orders",
"version": "https://sanshain.corp/producers.html?service=orders&api_type=openapi&version=2.0.0",
"graph": "https://sanshain.corp/graph.html"
}
}
-
idis a UUID, unique per event; a redelivery carries the sameid, so receivers can de-duplicate on it. occurred_atis RFC 3339, UTC, second precision.-
linksholds deep links into the UI and is omitted when Sanshain does not know its public base URL:PUBLIC_BASE_URL, else the origin of the OIDC redirect URL. Per type:producer,versionandgraphforversion.publishedandversion.deprecated;producerandauditforprovide.rejected;consumer,producer,versionandgraphfordependency.recorded;graphforbranch.cut;producer,consumerandgraphforcatalog.recorded;adminforwebhook.test.
Three headers accompany every delivery, in every format:
| Header | Value |
|---|---|
Sanshain-Event |
The event type. |
X-Sanshain-Delivery |
The delivery id, one per hook per event; a redelivery reuses it. |
Sanshain-Signature |
t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">
under the hook's secret.
|
Content-Type is application/json, User-Agent is
Sanshain/2.6.0. There are no custom headers and no bearer tokens: the signature is
the authentication.
Verifying the signature#
The secret (whsec_ and 64 hex characters) is shown once at creation or rotation.
Compute HMAC-SHA256 over the timestamp, a dot and the raw body, compare in
constant time, and reject timestamps older than five minutes to defeat replays:
Node.js (Express with the raw body kept)
const crypto = require("node:crypto");
function verify(req, secret) {
const header = req.get("Sanshain-Signature") || "";
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${req.rawBody}`).digest("hex");
const given = Buffer.from(parts.v1 || "", "hex");
return given.length === 32 && crypto.timingSafeEqual(given, Buffer.from(expected, "hex"));
}
Python
import hmac, hashlib, time
def verify(headers, raw_body: bytes, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in headers["Sanshain-Signature"].split(","))
t = int(parts["t"])
if abs(time.time() - t) > 300:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
Sign the body bytes exactly as received; do not re-serialise the JSON first.
Formats#
A hook's format picks the body shape; headers and signature are the same for all
three. Formats are body shapes, not connectors: every one is a signed POST to the URL
you give. The text is one line per event (for the example above:
Producer orders published as GA: openapi 2.0.0 on trunk (major change against 1.4.0), by
alice), and the link is the most specific one Sanshain knows.
slack: what a Slack incoming webhook accepts, mrkdwn with &,
<, > escaped in the text
{
"text": "*version.published* — Producer orders published as GA: openapi 2.0.0 on trunk (major change against 1.4.0), by alice <https://sanshain.corp/producers.html?service=orders&api_type=openapi&version=2.0.0|Open in Sanshain>"
}
teams: the Adaptive Card message a Teams incoming webhook (Workflows) accepts, with
\ * _ [ ] escaped in the text
{
"type": "message",
"attachments": [
{
"contentType": "application/vnd.microsoft.card.adaptive",
"contentUrl": null,
"content": {
"type": "AdaptiveCard",
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"version": "1.4",
"body": [
{ "type": "TextBlock", "text": "version.published", "weight": "Bolder", "size": "Medium" },
{
"type": "TextBlock",
"text": "Producer orders published as GA: openapi 2.0.0 on trunk (major change against 1.4.0), by alice",
"wrap": true
}
],
"actions": [
{
"type": "Action.OpenUrl",
"title": "Open in Sanshain",
"url": "https://sanshain.corp/producers.html?service=orders&api_type=openapi&version=2.0.0"
}
]
}
}
]
}
Without a known base URL the Slack link and the Teams actions are left out.
The outbox and the worker#
Every event is written to the outbox in the same database transaction as the fact it describes,
with its fan-out already decided, so a receiver outage loses nothing and a hook registered later
does not receive history. Two events have no fact to join and go to the outbox on their own:
provide.rejected and a catalog.recorded for a file Sanshain could not
read. WEBHOOKS_ENABLED=false turns the outbox off at the source: nothing is written,
so enabling it later does not replay a backlog.
A worker in each replica runs one pass every WEBHOOK_WORKER_INTERVAL_SECS (5): it
claims up to 50 due deliveries with a lease sized for the worst-case pass (50 × the delivery
timeout + 60 s), so a pass that dies mid-send loses nothing for good and a slow one is never
overtaken, and an outcome is recorded only while the row still carries that pass's lease. On
PostgreSQL the claim is FOR UPDATE SKIP LOCKED, which is what makes two replicas
safe; SQLite has one writer. The log says Webhook pass: 3 delivered, 1 failed, 0 dead
after a pass that did something.
Retries, the cutoff and redelivery#
-
A
2xxanswer is a delivery; the body of the answer is never read. Any other status (the receiver answered HTTP 500), a connection error or theWEBHOOK_DELIVERY_TIMEOUT_SECS(10) timeout is a failed attempt. -
Failed attempts are retried with exponential backoff: 30 s after the first failure, 1 min, 2
min, 4 min, 8 min, 16 min, 32 min, then every hour,
until 24 hours after the event occurred; a retry that would fall past that
point is not scheduled and the delivery is
dead. The window is measured from the event, not from the first attempt, so a hook paused for a day finds its deliveries dead on resume, with the reasongiven up: 24 hours have passed since the event. -
Redeliver resets one delivery, a dead one included, for one more attempt with
the same
idand delivery id; it keeps the attempt count and is attempted even past the cutoff, once. A delivery a worker holds at that moment answers409delivery 12 is in flight — a worker is attempting it right now; try again after that attempt. -
The delivery log (Deliveries on a hook) shows the newest deliveries with the
event type, when it occurred, the status (
pending,delivered,failed,dead), the attempt count, the next attempt, the last response code and the last error. Delivered and dead rows older than 30 days are removed by the cleanup pass.
Variables and metrics#
| Variable | Default | Effect |
|---|---|---|
WEBHOOKS_ENABLED |
true |
Write events and run the worker. false: nothing is written or sent, hooks can
still be managed, Send test answers 409
webhooks are disabled on this instance (WEBHOOKS_ENABLED=false): a test event would never
be delivered.
|
WEBHOOK_WORKER_INTERVAL_SECS |
5 |
Seconds between worker passes, per replica. |
WEBHOOK_DELIVERY_TIMEOUT_SECS |
10 |
Seconds one POST may take; also sizes the lease. |
PUBLIC_BASE_URL |
unset | Base of the deep links; falls back to the OIDC redirect URL's origin. |
The Prometheus exposition carries
sanshain_webhook_deliveries_total{outcome} (delivered,
failed, dead) and the gauge
sanshain_webhook_outbox_pending, refreshed after every pass. A pending gauge that
climbs while failed climbs is a receiver that is down or a host that left the
allow-list; one that climbs without attempts is a worker that is not running (WEBHOOKS_ENABLED=false
leaves the outbox empty instead).
Checked against v2.6.0.