2026-09-27·by Sijie Wang#standmeet#architecture#design#events

webhooks

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

OpWhat it does
webhooks.list / .create / .update / .deleteEndpoint CRUD; update can turn an endpoint off, and turning it on clears a failure
webhooks.rotate_secretShows the new secret once
webhooks.send_testSends a webhook.test event to that endpoint only
webhooks.deliveriesReads the webhook.deliver job rows for that endpoint: attempts, state, last error, a link to the job
webhooks.redeliverDelivers again every discarded delivery of the endpoint, after the receiver is fixed
webhooks.event_typesLists 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

One endpoint

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

#EventsPhase
—corpus.note.changed, webhook.testP1, P2
75–76access_request.created / .approved / .status_changedP2
77–78code.issued / .revoked / .redeemedP2
79–82conversation.started / .message / .pruned, ghost.acceptedP2
83booking.created / .cancelled / .rescheduledP4
84–85application.committed, jobs.fetchedP4
86–88writing.published / .unpublished, vault.importedP2
89–90microsite.build.settled, page.promoted_live / .rolled_back / .unpublished, microsite.store.doc_insertedP4
91–93api_key.issued / .revoked, supplier.connected / .disconnected / .activated, block.installed / .failedP2
94–97gas.exhausted / .refilled, instance.upgrade_requested, owner.login / .email_changed / .recovery_requested, ip_ban.addedP2

Webhook deliveries never coalesce: two events about one subject are two facts (relay-claims-rows-not-cursor).

about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →

webhooks