---
id: platform/webhooks
title: Outbound webhooks
description: Register a signed HTTPS receiver for Bex workspace events and verify Standard Webhooks deliveries.
keywords: [bex, webhooks, standard webhooks, deploy events, signing secret]
sidebar_position: 24
last_updated: 2026-09-23
---

# Outbound webhooks

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`](/examples/webhook-receiver-latest.zip)
([README](/examples/webhook-receiver/README.md)). Personal email and push
preferences are separate — see [Notifications](./notifications.md).

**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](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md) |

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](./api.md) and [Hosted setup](./hosted-setup.md) for how
tokens and workspaces relate. For a user-consented PKCE sample, see
[User-authorized OAuth integrations](./oauth-integrations.md).

`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):

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

```bash
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:

```json
{
  "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):

```json
{
  "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.md) / [API reference](./api-reference.md)) — 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

```bash
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:

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