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#

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.

The Webhooks section of the admin page: the allow-list, the registration form, and one global hook in Slack format marked exempt with Pause, Send test, Rotate secret, Deliveries and Delete
Global hooks and the allow-list on the admin page.
The Webhooks card on a Producer page with the registration form and the empty hook list
A Producer's own hooks, on its page.

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).

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"
  }
}

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#

From a fact to a delivery A request handler writes the fact and the event's outbox rows in one transaction; fan-out is decided then. A worker in every replica claims due deliveries with a lease every 5 seconds and POSTs each to its receiver. A 2xx answer is delivered. Anything else is a failed attempt, retried after 30 seconds, doubling, at most one hour apart, until 24 hours after the event, then dead. Redeliver resets a delivery, a dead one included, for one more attempt. request handler one transaction the fact (provide…) + one outbox row/hook fan-out decided here commit database outbox status, next attempt attempts, lease one row per hook per event claim every replica worker every 5 s, batch of 50 POST, 10 s timeout allow-list checked again answer 2xx → delivered else → failed retry: 30 s, ×2, ≤ 1 h dead 24 h after the event redeliver: one more attempt, same id delivered and dead rows are removed after 30 days
The outbox pattern: the event commits with the fact or not at all; delivery is a separate concern with its own retries.

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#

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.