# Webhooks

> Receive events in real time, verify their signature, understand automatic retries, and replay a delivery.

Webhooks notify you **as soon as something happens**: message received, sent, delivered, read, failed, template approved or rejected. Bref Point Inbox sends a JSON `POST` request to the URL you declare.

## Register an endpoint

<ApiExample method="POST" path="/webhook-endpoints" />

<Callout type="warning" title="The secret is shown only once">
The `secret` field (`whsec_…`) is used to **verify the signature** of each event. Store it in your environment variables. It is renewed with `POST /webhook-endpoints/{id}/rotate-secret`; the old one then stops working.
</Callout>

The URL must be **`https://`** and publicly reachable. `http://` URLs, `localhost`, private networks, and internal hostnames are rejected. To develop locally, expose your server through a public HTTPS tunnel.

Endpoints are specific to an **environment**: those in the sandbox never receive production events.

### Verify the endpoint

<ApiExample method="POST" path="/webhook-endpoints/{id}/test" />

We send a `webhook.test` event signed like the others and return your server's response (`ok`, HTTP code, duration, or the connection error).

## Events

| Type | When | Contents of `data` |
|---|---|---|
| `message.received` | A customer writes to you | `messageId`, `waMessageId`, `phoneNumberId`, `from`, `type`, `content`, `contactName`, `occurredAt` |
| `message.sent` | The message is handed to Meta | `messageId`, `waMessageId`, `phoneNumberId`, `to`, `status`, `occurredAt` |
| `message.delivered` | Delivered to the recipient's device | same, plus `pricing` if Meta provides it |
| `message.read` | Read by the recipient | same |
| `message.failed` | Rejected or not delivered | `messageId`, `phoneNumberId`, `to`, `status`, `occurredAt`, `error` (`code`, `message`) |
| `template.status_updated` | Meta approves, rejects, or pauses a template | `templateId`, `wabaConnectionId`, `name`, `language`, `status`, `previousStatus`, `reason` |

Subscribe an endpoint to `*` to receive everything, or to a specific list. `PATCH /webhook-endpoints/{id}` changes the subscription.

### The received body

```json title="POST to your URL"
{
  "id": "8dc9eca4-2eff-46de-80aa-0ac9674dd1ae",
  "type": "message.delivered",
  "createdAt": "2026-11-12T06:09:17.388Z",
  "data": { "messageId": "7a6d4b4c-…", "status": "delivered", "occurredAt": "2026-11-12T06:09:19.002Z" }
}
```

Each request also carries these headers:

| Header | Value |
|---|---|
| `BrefPoint-Signature` | `t=<seconds>,v1=<signature>`: see below |
| `BrefPoint-Event-Type` | The event type |
| `BrefPoint-Delivery-Id` | Identifier of this delivery |
| `Content-Type` | `application/json` |

## Verify the signature

**Always verify the signature**: without it, anyone could call your URL while pretending to be Bref Point Inbox.

The `v1` signature is the **HMAC-SHA256**, in hexadecimal, of the string `"<t>.<raw body>"`, using your `whsec_…` secret. `t` is the timestamp (in seconds) of the send: reject requests that are too old (5 minutes by default) to protect against replay.

<Tabs items={["Node.js", "Python", "PHP"]}>
<Tab>
<CodeFile src="verify-webhook.mjs" lang="javascript" />
</Tab>
<Tab>
<CodeFile src="verify-webhook.py" lang="python" />
</Tab>
<Tab>
<CodeFile src="verify-webhook.php" lang="php" />
</Tab>
</Tabs>

<Callout type="warning" title="The raw body, not re-encoded JSON">
The signature covers the **exact bytes received**. Compute it **before** any `JSON.parse` / `json_decode`: re-encoded JSON (whitespace, key order, accents) produces a different signature. With Express, use `express.raw({ type: "application/json" })` on this route.
</Callout>

These three examples are **run by Bref Point Inbox's tests** against the real signature: what you copy here works.

## Delivery reliability

- **Respond quickly** with a `2xx` code (maximum timeout: **10 seconds**), then process. A redirect (`3xx`) counts as a failure.
- A failure is **retried up to 8 times** in total, with a doubling wait: about 30 s, 1 min, 2 min, 4 min, 8 min, 16 min, then 32 min (roughly **one hour** overall).
- Events are delivered **at least once**: the `id` is stable across attempts, so **deduplicate on it**.
- **Order is not guaranteed**: a `message.read` can arrive before a `message.delivered`. Rely on `occurredAt`, and never move a status backward.
- After **15 permanently failed deliveries in a row**, the endpoint is **disabled** (`status: "disabled"`, with the reason). Re-enable it with `PATCH … { "enabled": true }` once your receiver is fixed.

## History and replay

Every delivery and every attempt is kept (according to your plan's retention).

<ApiExample method="GET" path="/webhook-deliveries/{id}" />

`attemptsDetail` gives, for each attempt, the response code, the duration, the error if any, and an excerpt of your server's response: enough to understand a failure without guessing.

Was your receiver down? Once it's fixed, **replay**:

<ApiExample method="POST" path="/webhook-deliveries/{id}/replay" />

A replay creates a **new delivery** of the same event (same event `id`) to the same endpoint; `replayOf` points to the original. It is rejected (`409`) if the delivery is still in progress or if the endpoint is disabled.
