Webhooks: thin, signed, scoped
Parent: events
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
One endpoint
- 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).