Bex can POST thin, signed event notifications to an HTTPS endpoint you control.
This guide covers the implemented registration contract and delivery
identity. A runnable verifier is published as
webhook-receiver
(README). Personal email and push
preferences are separate — see Notifications.
Source map (record these revisions when you refresh the guide):
| Concern | Authority |
|---|---|
| CMS docs | this repository (eden-cms-v2) |
| REST routes, create body, secret mint | bex @ 0eb2036e2 — lego/backend/internal/webhooks/rest.go |
| Admin authorization, URL rules, resend | …/webhooks/service.go |
| Thin payload + attempt headers | …/webhooks/worker.go |
| HMAC scheme + reference vector | …/webhooks/signing.go, signing_test.go |
| Protocol | Standard Webhooks |
Replace placeholders such as $BEX_API_URL, $BEX_TOKEN, and $OWNER_ID with
values from your installation. Do not paste real secrets into tickets or logs.
Who can administer endpoints
Webhook administration requires a workspace administrator user token with the OAuth scopes your installation expects for member-admin APIs. A machine API-key token has the developer role and cannot create or manage endpoints. See API permissions and Hosted setup for how tokens and workspaces relate. For a user-consented PKCE sample, see User-authorized OAuth integrations.
ownerId is the workspace id that owns the endpoint. Pass it on create and on
every subsequent read that scopes to a workspace.
Choose events
List the live vocabulary (do not hard-code an outdated enum):
curl --fail-with-body "$BEX_API_URL/v1/webhooks/event-types" \
-H "Authorization: Bearer $BEX_TOKEN"Common deploy transitions include deploy_started and deploy_ended. Many
other service, datastore, and ops names appear in the same list. An empty
eventFilter means “all events” after create-time normalization; prefer an
explicit subset for production receivers.
Register an HTTPS endpoint
The destination must be a public https:// URL. Bex refuses private, loopback,
and link-local addresses and does not follow redirects.
curl --fail-with-body -X POST "$BEX_API_URL/v1/webhooks" \
-H "Authorization: Bearer $BEX_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"ownerId\": \"$OWNER_ID\",
\"name\": \"ops-pager\",
\"url\": \"https://hooks.example.com/bex\",
\"enabled\": true,
\"eventFilter\": [\"deploy_started\", \"deploy_ended\"]
}"Required JSON fields: ownerId, name, url, enabled, and eventFilter
(array; may be empty for all events). A successful create returns 201 with a
body shaped like:
{
"id": "whk-…",
"name": "ops-pager",
"url": "https://hooks.example.com/bex",
"eventFilter": ["deploy_started", "deploy_ended"],
"enabled": true,
"secret": "whsec_…"
}The secret field appears only on this create response. Store it as
whsec_… and never log it. Later GET/PATCH responses omit the secret.
Manage endpoints from the dashboard Webhooks panel or with
GET/PATCH/DELETE /v1/webhooks/{id}?ownerId=….
What each delivery contains
Bex signs every attempt with Standard Webhooks headers:
| Header | Meaning |
|---|---|
webhook-id | Stable event id (evt-…). Same across retries of that event. |
webhook-timestamp | Unix seconds for this attempt. |
webhook-signature | v1, + base64 HMAC-SHA256 of id.timestamp.body with the raw secret bytes. |
The body is intentionally thin — type, timestamp, and resource identifiers. Example shape (field names as delivered):
{
"type": "deploy_ended",
"timestamp": "2026-09-15T12:00:00Z",
"data": {
"id": "evt-…",
"serviceId": "srv-…",
"serviceName": "api",
"status": "succeeded"
}
}data.status appears on terminal outcomes (succeeded / failed /
canceled) and is omitted for nonterminal types. Hydrate authorized detail
with the platform API (for example event or service reads documented under
API / API reference) — do not assume the
webhook body contains full resource state.
Verify the signature against the raw request bytes before parsing JSON.
Reject tampered or unverifiable attempts with a non-2xx response so Bex can
retry. Use webhook-id for receiver-side deduplication; attempt timestamps
change on retry.
Delivery history and recovery
curl --fail-with-body \
"$BEX_API_URL/v1/webhooks/$WEBHOOK_ID/events?ownerId=$OWNER_ID" \
-H "Authorization: Bearer $BEX_TOKEN"Automatic retries use the worker's backoff schedule (implementation detail, not
an SLA). After repeated failures an endpoint can be disabled — fix the receiver,
then re-enable with PATCH /v1/webhooks/{id}?ownerId=… ("enabled": true).
Manual resend of a failed attempt:
curl --fail-with-body -X POST \
"$BEX_API_URL/v1/webhooks/$WEBHOOK_ID/events/$ATTEMPT_ID/resend?ownerId=$OWNER_ID" \
-H "Authorization: Bearer $BEX_TOKEN" \
-H "Idempotency-Key: resend-$(date +%s)"Idempotency-Key must be 8–128 characters of letters, digits, ., _, :, or
-. Resend identity is separate from the event's stable webhook-id: the
receiver still deduplicates on webhook-id, while the platform uses your
Idempotency-Key to avoid double-queueing the same admin resend.
Webhook delivery is asynchronous. Do not treat a successful deploy as proof that every subscriber processed the event.
Next step
Run examples/webhook-receiver/server.mjs with WEBHOOK_SECRET from the create
response, confirm signature verification and durable deduplication locally, then
enable the endpoint for production traffic.