Webhooks
Chorus webhooks are a durable, at-least-once outbound HTTP protocol. A domain mutation commits an immutable outbox event first. Workers later materialize one deterministic delivery per matching event-time subscription revision, lease it, and send the exact stored body and signature.
That boundary matters: a process may stop after the domain commit, after the
receiver accepts the request, or before Chorus records the response. The event
survives all three cases. A retry keeps the same event_id, delivery_id, raw
body, destination snapshot, and HMAC signature; only X-Chorus-Attempt changes.
Event sources and filters
Section titled “Event sources and filters”Signals use legacy filters, all ANDed:
rings—signal.to_ringmust be listed.types—signal.signal_typemust be listed.min_urgency—signal.urgencymust meet the threshold.
An absent signal filter matches every signal. Execution transitions require an
explicit transition_filters object:
resource_kinds—task | run | delivery | checkout.task_states,run_states,delivery_states,checkout_states.transition_classes—success | failure | terminal | active | blocked.
Approval audit events also require an explicit opt-in. Set approval_events to
one or more canonical approval event types such as approved, denied, or
execution_token_consumed. A webhook with no filters, and a legacy webhook
created before this field existed, receives signals but never receives approval
events. This avoids disclosing approval audit records through an old catch-all
signal subscription.
Registration and payload bounds
Section titled “Registration and payload bounds”Webhook registration is bounded before it can participate in a domain commit:
- URLs retain the established 16,384-character limit.
- Ring and signal-type filter values are 1–256 characters, with at most 64 values per list.
- Transition state/class/kind lists cannot exceed their canonical enum
cardinality;
approval_eventsaccepts only canonical event types. - At most 1,000 active subscription revisions and 1 MiB of exact encoded event-time snapshot data may be admitted.
- A base event may use at most 258,048 bytes. Chorus reserves 4 KiB for stable event, delivery, webhook, and timestamp fields so the final signed request is always at most 256 KiB.
Invalid legacy active rows are reported by ID during startup validation. They are not silently reinterpreted or deactivated; operators must repair them before admission can open.
Signal, approval, and native execution-transition producers append their outbox event in the same storage transaction as the domain mutation. A committed mutation therefore cannot be stranded behind a process crash before enqueue.
Credential-command idempotency
Section titled “Credential-command idempotency”Creating a registration and rotating its signing key both require an
idempotency_key: 8–128 visible ASCII characters chosen by the caller. Keep the
same key across transport retries of one logical command; do not generate a new
key for each attempt.
POST /admin/webhooks:
{ "url": "https://receiver.example/hook", "idempotency_key": "provision-receiver-v1"}POST /admin/webhooks/{webhook_id}/rotate-secret:
{ "idempotency_key": "rotate-receiver-2026-07" }The scope is authenticated actor + command + key. An exact retry returns the
same webhook ID and secret without another revision or audit row, including
after an ambiguous response. Reusing the key for a different URL/filter request
or a different webhook returns 409 WEBHOOK_IDEMPOTENCY_CONFLICT. Credential
receipts are internal, transactionally committed with the mutation and audit
row, and are not exposed through read/list APIs.
Stable payload and headers
Section titled “Stable payload and headers”The signed JSON body includes:
{ "signal": { "id": "signal:..." }, "event_id": "webhook_outbox_event:...", "delivery_id": "webhook_delivery:...", "webhook_id": "webhook:...", "timestamp": "2026-07-14T08:00:00.000Z"}Transition deliveries use transition instead of signal. Every request has:
| Header | Meaning |
|---|---|
X-Hub-Signature-256 |
Canonical sha256=<hex> HMAC over the exact raw body |
X-Chorus-Event-ID |
Stable source event ID |
X-Chorus-Delivery-ID |
Stable logical delivery ID; receiver dedupe key |
X-Chorus-Attempt |
Physical send number, starting at 1 |
X-GitHub-Event |
Signal type or transition.<kind> |
X-Chorus-Signature / X-Chorus-Event |
Deprecated 1.x compatibility aliases |
Verify X-Hub-Signature-256 against the raw request bytes before parsing JSON.
Then deduplicate by X-Chorus-Delivery-ID (or the identical body field) in the
same transaction as the receiver-side effect.
receive -> verify HMAC -> insert delivery_id if absent -> apply effect -> 2xx | +-- already present -> return 2xx, do not reapplyNever deduplicate by attempt number: attempts intentionally change across lease takeover and retry.
Event-time subscription and key revisions
Section titled “Event-time subscription and key revisions”Each outbox event snapshots the active webhook’s URL, filters, subscription
revision, secret_version, and signing secret. Later URL/filter/activation
changes increment revision; secret rotation increments both revision and
secret_version.
Rotate through:
POST /admin/webhooks/{webhook_id}/rotate-secretThe replacement secret is returned by the command and exact retries under its
idempotency key; ordinary reads never expose it. Already committed events keep
their old event-time destination and key; new events use the new revisions.
This lets an operator rotate without corrupting queued delivery signatures.
Keep the old key available at the receiver until all deliveries with its
secret_version are terminal.
Deleting or disabling a registration follows the same snapshot boundary: it prevents new domain events from selecting that webhook, but it does not rewrite or cancel deliveries already committed with the old destination. An external effect already in flight cannot be retracted; endpoint-side revocation and receiver deduplication remain the emergency controls.
Retry, dead letter, and replay
Section titled “Retry, dead letter, and replay”Automatic retry is limited to transport failures, HTTP 408, 425, 429, and
5xx. Other 4xx responses dead-letter immediately. The default attempt cap is
3, with 5-second, 30-second, and 5-minute delays. Retry-After is honored but
capped at 5 minutes.
Delivery states are:
pending -> leased -> sending -> delivered | +-> pending (retry scheduled) +-> dead_letterLeases carry an immutable generation. An expired leased or sending row can
be taken over, but the stale generation can no longer settle it. Attempt
transitions are append-only and retained to a maximum of 100 rows per delivery.
Manual replay is admin-only:
POST /admin/webhooks/deliveries/{delivery_id}/replayReplay resets a failed/dead-letter row to pending while preserving its stable
ID, exact body, destination, and signature. It is another at-least-once send, so
receiver dedupe is mandatory.
Operator surfaces
Section titled “Operator surfaces”Webhook delivery and outbox diagnostics are admin-only. Their public contract is
an explicit allowlist: reads omit current and event-time destinations, exact
payload and retry bodies, raw payload bytes, signing secrets and signatures,
filter snapshots, flexible error text, and arbitrary nested summary content.
Delivery payload_summary, when present, contains only stable event/aggregate
identifiers, signal_type, retryable, and content_redacted: true. REST, SDK,
and OpenAPI share this shape; MCP validates it strictly and fails closed if an
upstream response contains storage-only fields.
Webhook dead-letter counts and rows in /briefing are also global operational
data, not ring-scoped data. They are returned only to administrators. A regular
member receives webhook_dead_letters: 0 and, for a non-compact briefing,
recent_dead_letters: [], even when the member can read the requested ring.
Administrator rows use the same allowlisted delivery projection described
above.
| Operation | HTTP | SDK | MCP | CLI |
|---|---|---|---|---|
Create subscription (requires idempotency_key) |
POST /admin/webhooks |
admin.createWebhook() |
chorus_webhook_create |
admin webhooks create --idempotency-key ... |
| Update subscription | PATCH /admin/webhooks/{id} |
admin.updateWebhook() |
chorus_webhook_update |
admin webhooks update |
| List outbox | GET /admin/webhooks/outbox |
admin.listWebhookOutbox() |
chorus_webhook_outbox |
admin webhooks outbox |
| List deliveries | GET /admin/webhooks/{id}/deliveries |
admin.listWebhookDeliveries() |
chorus_webhook_deliveries |
admin webhooks deliveries |
| Attempt history | GET /admin/webhooks/deliveries/{id}/attempts |
admin.listWebhookDeliveryAttempts() |
chorus_webhook_delivery_attempts |
admin webhooks attempts |
| Replay | POST /admin/webhooks/deliveries/{id}/replay |
admin.replayDelivery() |
chorus_webhook_replay |
admin webhooks replay |
Rotate secret (requires idempotency_key) |
POST /admin/webhooks/{id}/rotate-secret |
admin.rotateWebhookSecret() |
chorus_webhook_rotate_secret |
admin webhooks rotate-secret --idempotency-key ... |
Outbox and attempt reads accept a maximum limit of 100. Outbox paging also
accepts status and after.
GET /admin/health-metrics exposes fixed-cardinality webhook_metrics: outbox
and delivery totals by state, ready/expired lease counts, and the retained
attempt-event count. It contains no webhook URLs, IDs, payload labels, or
identity labels.
Migration behavior
Section titled “Migration behavior”Migration 048 adds the outbox, lease/revision fields, and immutable attempt
table. Legacy pending/failed delivery rows that lack exact persisted bytes or a
signature are quarantined as dead_letter with
LEGACY_DELIVERY_PAYLOAD_NOT_DURABLE; Chorus never reconstructs and sends an
unsafe approximation.
Migration 056 adds the serialized active-subscription registry and the durable credential-command receipt table used by create and secret-rotation retries.