Skip to content

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.

Signals use legacy filters, all ANDed:

  • rings — signal.to_ring must be listed.
  • types — signal.signal_type must be listed.
  • min_urgency — signal.urgency must 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.

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

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.

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 reapply

Never deduplicate by attempt number: attempts intentionally change across lease takeover and retry.

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

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

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_letter

Leases 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}/replay

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

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