Registry stack documentation: machine-readable Markdown.
Index of all pages: https://docs.registrystack.org/v/0.38.0/llms.txt
Full corpus: https://docs.registrystack.org/v/0.38.0/llms-full.txt

# Bind webhook receivers

> Bind each event destination a Base Registry Engine package declares to a receiver, share the signing key, build the receiver against a sample request, and recover deliveries from the operator host.

You operate an active registry, or are about to activate one as [deploy a registry](../breg/)
describes, whose project declares events that name a `webhook` destination, and a receiver is
waiting for them. At the end of this page every destination identifier the package names has a
binding in the runtime file, the receiver holds the same signing key and has been built against a
sample request, and you can list and recover deliveries.

An event is a declaration in the project, a trigger on an entity with a projection of its fields,
that the runtime delivers after the write commits. A destination is the receiver the declaration
names by identifier; the package carries only that identifier, and the runtime file binds it to an
origin, so a module stays deployment-neutral.
[Send registry events to a webhook](../../tutorials/send-registry-events-to-a-webhook/) walks
a first delivery on a local registry, and the events section of
[author a registry project](../../configure/breg/) covers the declaration.

## Bind each destination

The package refuses to activate until the runtime file binds every destination identifier it
names, and it refuses extra bindings. Destination URLs and secret references belong in the runtime
file, never in a module:

```yaml
eventDestinations:
  record-receiver:
    origin: https://receiver.example.org
    path: /events
    networkProfile: productionHttps
    dnsFamily: dualStackStrict
    allowedPrivateCidrs: []
    hmacSha256KeyRef: secret:file/record-webhook-key
    classificationCeiling: restricted
    deliveryCeilings:
      attemptTimeoutMilliseconds: 1000
      maximumAttempts: 3
eventDelivery:
  payloadRetentionDays: 7
```

The destination's `classificationCeiling` must cover the event's derived classification; a binding
cannot add fields to a payload. For a receiver on a private network, list the intended canonical
ranges in `allowedPrivateCidrs`. Use `dnsFamily: ipv4Only` only on an IPv4-only network. Optional
`tls.caBundleRef` and `tls.clientIdentityRef` name PEM secrets for a private certificate authority
and mutual TLS. `networkProfile: loopbackDevelopmentHttp` exists for local development only.
The `productionHttps` profile refuses `localhost` and every `*.localhost` name during
configuration validation, including absolute names with a trailing dot. Use the loopback profile
for a receiver on the same development host.

Lifecycle events whose transition and state filters permit application have an `internal`
classification floor: their system request envelope may contain the applier's explanation.
Filters excluding application retain the event's ordinary derived classification.
The destination must permit that classification even when the record projection contains only
public fields. Reader grants that hide reason text do not change webhook delivery authority.

{/* Evidence: crates/registry-breg/src/compiler.rs request_event_may_include_reason and compile_event_delivery_inventory; crates/registry-breg/src/request_events.rs. */}

A binding may tighten the compiled ceilings and never widen them; the example tightens both.
`bregctl explain events <project>` reports the compiled values per delivery: five attempts with a
five-second timeout each, retried after 1, 2, 4, and 8 seconds, with a dead letter required when
the attempts run out. Automatic retries keep the event id and idempotency key. Any `2xx`
acknowledges delivery; redirects, timeouts, and other responses follow the bounded retry policy.
Activation refuses a rebinding that would leave retained pending or dead-lettered deliveries
without the binding they were captured under. `bregctl doctor` opens every binding before the
process starts and reports one it cannot open as `startup.event_destinations.refused`, so run it
after every change to this block.

{/* Evidence: crates/registry-breg/src/event_destination.rs, RawEventDestinationConfig;
    crates/registry-breg/src/runtime_config.rs;
    crates/registry-breg/src/compiler.rs, WEBHOOK_ATTEMPT_TIMEOUT_MS and WEBHOOK_MAXIMUM_ATTEMPTS;
    crates/registry-breg/src/webhook.rs;
    crates/registry-bregctl/src/doctor.rs. */}

## Provision the shared key

Generate the HMAC-SHA-256 key as an owner-only file inside the secret root, as the user that runs
`breg`, and provision the identical bytes to the receiver through its own secret store:

```sh
(umask 077; openssl rand -hex 32 | tr -d '\n' > /etc/breg/secrets/record-webhook-key)
```

The runtime signs with the file's bytes exactly as written, neither trimmed nor decoded, so the
receiver must load the same 64 characters as its key rather than decode them. The key needs at
least 32 bytes, and the file must be a regular file with mode `0400` or `0600` owned by the running
user; `doctor` and startup refuse a binding whose key breaks either rule.

{/* Evidence: crates/registry-breg/src/event_destination.rs, MIN_HMAC_SHA256_KEY_BYTES;
    crates/registry-platform-config/src/secrets.rs, read_secret_file() and validate_file_metadata(). */}

## Build the receiver against a sample

Your receiver verifies `X-Registry-Signature` against the exact request bytes and checks
`X-Registry-Delivery-Time` for freshness before acting. `webhook sample` prints one request for
an event with placeholder identifiers and a placeholder signature, without a runtime or a
database, so the receiver can be built before the registry serves:

```sh
bregctl webhook sample ./my-registry --event record-created-v1
```

```text
Built the sample delivery. The canonical request follows.
  event  record-created-v1

POST <configured-webhook-request-target> HTTP/1.1
Accept: application/json
Content-Type: application/json
Idempotency-Key: sha256:0000000000000000000000000000000000000000000000000000000000000000
X-Registry-Delivery-Attempt: 1
X-Registry-Delivery-Time: 2026-01-01T00:00:00Z
X-Registry-Event-Generation: 1
X-Registry-Signature: v1=<computed-at-delivery>
ce-dataschema: urn:breg:event-schema:generic-registry:record:record-created-v1:sha256:411f35fcbaa498497ac172fdb725d2f583d761a73d903c1647b34dc4af703855
ce-id: 00000000-0000-4000-8000-000000000001
ce-source: urn:registrystack:registry:generic-registry:instance:<configured-instance>
ce-specversion: 1.0
ce-time: 2026-01-01T00:00:00Z
ce-type: record-created-v1

{"causation":{"hop":0,"root":"00000000-0000-4000-8000-000000000001"},"data":{"entity":"record","packageRevision":"sha256:0000000000000000000000000000000000000000000000000000000000000000","recordId":"00000000-0000-4000-8000-000000000002","revision":1,"trigger":"created","values":{"code":"x","status":"draft"}},"dataschema":"urn:breg:event-schema:generic-registry:record:record-created-v1:sha256:411f35fcbaa498497ac172fdb725d2f583d761a73d903c1647b34dc4af703855","id":"00000000-0000-4000-8000-000000000001","source":"urn:registrystack:registry:generic-registry:instance:<configured-instance>","subject":{"recordReference":"hmac-sha256:0000000000000000000000000000000000000000000000000000000000000002","recordRevision":1},"time":"2026-01-01T00:00:00Z","type":"record-created-v1"}
```

That sample carries `generic-registry`, the registry id `bregctl init` writes into `registry.yaml`;
your project carries its own id, schema hash, and projected fields. `--format json` returns the
same request as a document with the method, request target, headers, body, and canonical body. The
[API reference](../../reference/breg-api/) defines the request shape and the signing input byte
order. Deduplicate retries by `Idempotency-Key`, use the record revision to order events, and
acknowledge only after durably recording acceptance.

{/* Evidence: crates/registry-bregctl/src/lib.rs, WebhookSampleArgs;
    crates/registry-bregctl/src/webhook_lifecycle.rs;
    crates/registry-breg/src/webhook.rs. */}

## Inspect and recover deliveries

Inspect pending, leased, dead-lettered, and expired deliveries, then replay a dead letter after
fixing the receiver:

```sh
bregctl --format json webhook list \
  --runtime-config /etc/breg/runtime.yaml --limit 50

bregctl webhook replay \
  --runtime-config /etc/breg/runtime.yaml \
  --event-id "<eventId>" --delivery-id "<deliveryId>" \
  --expected-generation "<generation>"
```

The list carries delivery metadata and no record values, and `--limit` accepts 1 to 100. A
delivery that exhausts its attempts becomes a dead letter with a closed `deadLetterReason` while
its payload is retained. Older deliveries, and deployments awaiting a schema update, may omit
that reason. `bindingActive` reports whether the exact captured binding is deployed;
`replayEligible` is true only while that binding and the retained payload are available.
Successful delivery erases the payload once no sibling delivery needs it, and retained payloads
expire after `payloadRetentionDays`, which defaults to 7 and is capped at 30. An empty list is not
proof of success: completed
deliveries are excluded, so confirm effects from the receiver's own record. Replay keeps the event
id and issues a new generation and idempotency key; if the receiver's earlier effect is uncertain,
check its state before replaying. A replay whose `--expected-generation` no longer matches, whose
payload has expired, or whose destination is no longer bound is refused with
`webhook.operation.refused`.

If `doctor` reports `startup.webhook.retained_bindings` after you correct a destination or local
handler binding, keep the corrected configuration and use `webhook list`. Rows with
`bindingActive: false` name the retained work that still pins the earlier binding. Restore that
exact binding until those rows drain, or stop the runtime and discard each eligible row with the
generation returned by the list:

```sh
bregctl webhook discard \
  --runtime-config /etc/breg/runtime.yaml \
  --event-id "<eventId>" --delivery-id "<deliveryId>" \
  --expected-generation "<generation>"
```

::::danger[Discard prevents every future attempt]
Discard permanently closes this delivery without sending it under the corrected binding. It does
not undo a request the receiver may already have accepted. Check the receiver before discarding
when an earlier effect is uncertain.
::::

`discardEligible: true` identifies pending work, dead letters with retained payload, and leases
whose deadline has passed when no proposal receipt is retained. A live lease or any retained
proposal receipt is refused. A stale generation is also refused, so refresh the list before
retrying. After discard, the row remains visible as `expired` with a new generation and without
payload availability. Discarding one delivery leaves the shared payload available while a sibling
delivery still needs it.

Operator commands connect to PostgreSQL directly. When the database uses a private certificate
authority, set `SSL_CERT_FILE` to its PEM bundle for each command.

{/* Evidence: crates/registry-breg/src/webhook.rs, MAX_WEBHOOK_STATUS_RESULTS;
    crates/registry-breg/src/outbox.rs;
    crates/registry-bregctl/src/webhook_lifecycle.rs;
    crates/registry-bregctl/src/lib.rs, WebhookListArgs, WebhookReplayArgs, and WebhookDiscardArgs;
    products/breg/quickstart/run.sh. */}

## Troubleshooting

| Symptom | Next move |
| --- | --- |
| Activation refuses the package over destinations | Every destination identifier the package names needs one binding, and no binding may name an identifier the package does not. Compare the `eventDestinations` keys with `bregctl explain events`. |
| `doctor` reports `startup.event_destinations.refused` | Check the key file's owner, mode, and length, the origin against the network profile, and `allowedPrivateCidrs` for a private receiver. |
| `doctor` reports `startup.webhook.retained_bindings` | Run `webhook list` under the corrected configuration. Restore the exact captured binding until each row drains, or stop the runtime and discard each row where `discardEligible` is true. Wait for a live lease to expire before retrying discard. |
| The receiver rejects every signature | The runtime signs the raw file bytes. Load the same characters on the receiver without decoding them, and verify against the exact request bytes in the documented signing-input order. |
| `doctor` reports `startup.instance_id.pending_deliveries` | Pending deliveries were captured under the event source of a previous `identity.instanceId`, and the worker would dead-letter them under the new one. Restore the instance id that ends the stored source the diagnostic names until `webhook list` shows them drained, then change it. |
| A webhook never fires | Confirm the destination is bound, the receiver returns `2xx`, and the event's condition matched; use `webhook list` for dead letters. |
| Replay is refused | Refresh the list and check the generation, `replayEligible`, payload expiry, and the destination binding. |

## Next

- [Send registry events to a webhook](../../tutorials/send-registry-events-to-a-webhook/) to
  see one delivery end to end on a local registry.
- [Author a registry project](../../configure/breg/) for the event declaration, its projection,
  and its classification.
- [Base Registry Engine API reference](../../reference/breg-api/) for the delivery request shape,
  the signing input, and the CloudEvents headers.
- [Base Registry Engine configuration reference](../../reference/breg-configuration/) for every
  `eventDestinations` and `eventDelivery` key.