Webhooks: thin, signed, scoped
Status: released in v0.1.76 (2026-09-27) — design and as-built record in docs/design/event-bus-outbox-webhooks.md in the StandMeet repo.
A webhook is a subscriber outside the instance. Delivery is two job kinds in the owner domain (internal/owner/subscriber/webhooks.go):
webhook.fanoutsubscribes to everyWebhook-exposed type. It loads the owner's enabled endpoints, matches the type globs, applies the scope, and enqueues onewebhook.deliver {endpoint_id, event_id}per endpoint in one transaction. The insert is unique by args, so a fan-out that runs twice queues each delivery once.webhook.delivertakes the endpoint's lease, reads the event and the secret, signs a thin payload with the Standard Webhooks headers, posts it, then settles the endpoint's state. River owns the retries (retry-has-one-owner).
Endpoints
webhook_endpoints (id, owner_id, url, description, event_types text[] -- glob list,
secret_enc, embed_id NULL -- scope source, enabled,
disabled_reason, failing_since, busy_until, created_at, updated_at)
The owner domain declares the ops once. MCP and admin (/api/admin/webhooks*) are projections of them.
| Op | What it does |
|---|---|
webhooks.list / .create / .update / .delete |
Endpoint CRUD; update can turn an endpoint off, and turning it on clears a failure |
webhooks.rotate_secret |
Shows the new secret once |
webhooks.send_test |
Sends a webhook.test event to that endpoint only |
webhooks.deliveries |
Reads the webhook.deliver job rows for that endpoint: attempts, state, last error, a link to the job |
webhooks.redeliver |
Delivers again every discarded delivery of the endpoint, after the receiver is fixed |
webhooks.event_types |
Lists the types whose Exposure is Webhook |
Admin has a Webhooks section under Integrations: endpoint list, create form with URL and event-type checkboxes, one-time secret reveal, delivery log (each row links to /admin/tasks?job=<id>), "send test". Endpoints are instance configuration, like suppliers.
Wire format: Standard Webhooks + thin event
POST <endpoint url>
webhook-id: <event id>
webhook-timestamp: 1790412000
webhook-signature: v1,<base64 HMAC-SHA256(key, id.timestamp.body)>
{ "id": "…", "type": "corpus.note.changed",
"subject": "wiki://software/project/standmeet/architecture", "occurred_at": "…",
"data": { "op": "updated", "note_id": "…", "published": true, "was_published": true } }
- The payload carries the type, the subject and ids. It carries no body. The consumer reads the resource through the public or scoped API it already uses. A delete carries the last known subject.
webhook-idequals the event id on every retry. Consumers dedupe on it (message-loss-guarantees).- The secret is per endpoint:
whsec_+ base64 of 32 random bytes; the HMAC key is the decoded part. It is sealed at rest withcryptoboxand unsealed only incmd/server/unseal.go. - The post goes through
httpx.NewClient{BlockInternalEgress: true, NoRetry: true}, with a 10 s HTTP timeout inside the 15 s job timeout. A private, loopback or redirect-to-private target is blocked and discarded at once. - A UT checks the signer against the Standard Webhooks test vector.
Scope: one predicate, raw never leaves
Only corpus.note.changed is scoped. Every other type goes to every endpoint that subscribes to it (webhook.test goes only to the endpoint it names).
raw://never leaves the instance.- A standalone endpoint gets the published slice: a note change goes out when the note is published, or was published a moment ago (
publishedorwas_published), so an unpublish is heard. - An endpoint attached to an embed gets that embed's code scope. The access domain answers it (
EmbedAdmits) with the single ACL predicateentity.AllowsCorpusEntry: role globs minus the code's denials.publishedcounts only where the code's role reads the published slice. A revoked or expired code, or a deleted embed, admits nothing. The answer is asked on every event, because a code can be revoked or re-scoped at any time (embed-update-hook). - The embed scope is a function handed in by the composition root, so the owner domain's subscriber does not import the access domain's internals and
infra/eventsimports no domain. Exposuredefaults toInternal. A new event type cannot leave the instance by accident (event-model).
One delivery
stateDiagram-v2 [*] --> pending : webhook.fanout (scope passed, 5 min out while the endpoint is failing) pending --> running : worker claims (SKIP LOCKED) running --> pending : endpoint busy → snooze 2 s (no attempt spent) running --> completed : 2xx running --> pending : 429 / 503 with Retry-After → snooze (no attempt spent) running --> retryable : 5xx / 408 / 429 / timeout / connection failure running --> discarded : other 4xx · SSRF-blocked · endpoint disabled or gone · event pruned retryable --> pending : backoff 5s·5m·30m·2h·5h·10h… retryable --> discarded : 18 attempts (about 5.3 days) completed --> [*] discarded --> [*]
One endpoint
stateDiagram-v2 [*] --> enabled : webhooks.create (secret shown once) enabled --> failing : any non-success (failing_since set) failing --> enabled : any delivery succeeds (failing_since cleared) failing --> disabled : 5 days of continuous failure (reason recorded) disabled --> enabled : owner turns it on (clears the failure) enabled --> enabled : rotate_secret / send_test enabled --> [*] : webhooks.delete disabled --> [*] : webhooks.delete
- Lease. At most one delivery per endpoint is in flight, through a lease row (
busy_until, taken withUPDATE … RETURNING). An advisory transaction lock was rejected: it would hold a transaction across the POST (concurrency-control). - Cooldown. While
failing_sinceis set, new deliveries are scheduled 5 min out, so a dead receiver is not hit once per new event. - Any non-success sets
failing_since, 410 and 429 included; a success clears it. A disabled endpoint gets no new deliveries.
Exposed event types
All 39 declared types are Webhook-exposed and thin; none is subscribed by default. They are facts of the owner's own instance; security events suit alerting. The list is in event-model; the sweep rows are in consolidation-inventory. An e2e (webhook-event-types) drives a real action for each type and asserts that a * endpoint receives it.
| # | Events | Phase |
|---|---|---|
| — | corpus.note.changed, webhook.test |
P1, P2 |
| 75–76 | access_request.created / .approved / .status_changed |
P2 |
| 77–78 | code.issued / .revoked / .redeemed |
P2 |
| 79–82 | conversation.started / .message / .pruned, ghost.accepted |
P2 |
| 83 | booking.created / .cancelled / .rescheduled |
P4 |
| 84–85 | application.committed, jobs.fetched |
P4 |
| 86–88 | writing.published / .unpublished, vault.imported |
P2 |
| 89–90 | microsite.build.settled, page.promoted_live / .rolled_back / .unpublished, microsite.store.doc_inserted |
P4 |
| 91–93 | api_key.issued / .revoked, supplier.connected / .disconnected / .activated, block.installed / .failed |
P2 |
| 94–97 | gas.exhausted / .refilled, instance.upgrade_requested, owner.login / .email_changed / .recovery_requested, ip_ban.added |
P2 |
Webhook deliveries never coalesce: two events about one subject are two facts (relay-claims-rows-not-cursor).