Webhooks: thin, signed, scoped

Updated · View the entry on sijie.xyz ↗

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.fanout subscribes to every Webhook-exposed type. It loads the owner's enabled endpoints, matches the type globs, applies the scope, and enqueues one webhook.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.deliver takes 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-id equals 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 with cryptobox and unsealed only in cmd/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 (published or was_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 predicate entity.AllowsCorpusEntry: role globs minus the code's denials. published counts 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/events imports no domain.
  • Exposure defaults to Internal. 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 with UPDATE … RETURNING). An advisory transaction lock was rejected: it would hold a transaction across the POST (concurrency-control).
  • Cooldown. While failing_since is 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).