Guides

Webhooks

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

Updated November 12, 2026View as Markdown

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#

bash
curl -X POST "$INBOX_API/webhook-endpoints" \
  -H "Authorization: Bearer $INBOX_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://api.example.com/webhooks/inbox",
    "events": [
      "message.received",
      "message.delivered",
      "message.failed"
    ]
  }'
Response · 201 Created
{
  "id": "bc06ae32-7564-46d5-89fb-a9869019aa8e",
  "url": "https://api.example.com/webhooks/inbox",
  "events": [
    "message.received",
    "message.delivered",
    "message.failed"
  ],
  "status": "active",
  "consecutiveFailures": 0,
  "disabledReason": null,
  "createdAt": "2026-11-12T08:33:45.704Z",
  "secret": "whsec_b4ac869893324714f88bfd25026b3e2e2891bb8c087"
}

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#

bash
curl -X POST "$INBOX_API/webhook-endpoints/ID/test" \
  -H "Authorization: Bearer $INBOX_KEY"
Response · 200 OK
{
  "ok": true,
  "statusCode": 200,
  "durationMs": 3,
  "error": null
}

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#

TypeWhenContents of data
message.receivedA customer writes to youmessageId, waMessageId, phoneNumberId, from, type, content, contactName, occurredAt
message.sentThe message is handed to MetamessageId, waMessageId, phoneNumberId, to, status, occurredAt
message.deliveredDelivered to the recipient's devicesame, plus pricing if Meta provides it
message.readRead by the recipientsame
message.failedRejected or not deliveredmessageId, phoneNumberId, to, status, occurredAt, error (code, message)
template.status_updatedMeta approves, rejects, or pauses a templatetemplateId, 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#

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:

HeaderValue
BrefPoint-Signaturet=<seconds>,v1=<signature>: see below
BrefPoint-Event-TypeThe event type
BrefPoint-Delivery-IdIdentifier of this delivery
Content-Typeapplication/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.

verify-webhook.mjs
// Verifying a Bref Point Inbox webhook — copy it into your server (Node.js ≥ 18, no dependencies).
//
// The `BrefPoint-Signature` header looks like: t=<seconds>,v1=<hmac-sha256-hex>
// The signature covers the string  "<t>.<raw body>"  using the endpoint's `whsec_…` secret.
import { createHmac, timingSafeEqual } from 'node:crypto';

/**
 * @param {string|Buffer} rawBody  the EXACT body received (before any JSON.parse)
 * @param {string} header          value of the BrefPoint-Signature header
 * @param {string} secret          the endpoint's whsec_… secret
 * @param {number} toleranceSeconds maximum accepted age (protects against replay), 300 s by default
 */
export function verifyWebhook(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries((header ?? '').split(',').map((p) => p.split('=')));
  const t = Number(parts.t);
  if (!Number.isFinite(t) || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
  const given = Buffer.from(parts.v1, 'hex');
  return given.length === expected.length && timingSafeEqual(given, expected);
}

// Exemple avec Express :
//   app.post('/webhooks/inbox', express.raw({ type: 'application/json' }), (req, res) => {
//     if (!verifyWebhook(req.body, req.get('BrefPoint-Signature'), process.env.INBOX_WEBHOOK_SECRET)) return res.sendStatus(401);
//     const event = JSON.parse(req.body);      // { id, type, createdAt, data }
//     // `event.id` is stable: if it comes back (retry, replay), ignore it if already processed.
//     res.sendStatus(200);                     // answer fast (2xx), process afterwards
//   });

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

bash
curl "$INBOX_API/webhook-deliveries/ID" \
  -H "Authorization: Bearer $INBOX_KEY"
Response · 200 OK
{
  "id": "473dfe39-7555-4942-8a5b-d2ec99026b44",
  "endpointId": "bc06ae32-7564-46d5-89fb-a9869019aa8e",
  "eventId": "2cbc3e2b-fba4-4246-868d-76ffe9fcb829",
  "eventType": "message.sent",
  "status": "succeeded",
  "attempts": 1,
  "lastStatusCode": 200,
  "lastError": null,
  "replayOf": null,
  "createdAt": "2026-11-12T08:33:46.267Z",
  "deliveredAt": "2026-11-12T08:33:46.324Z",
  "payload": {
    "data": {
      "to": "237611111111",
      "status": "sent",
      "messageId": "a9691d7a-ee51-4da9-87b2-24cce79a9313",
      "occurredAt": "2026-11-12T08:33:46.251Z",
      "waMessageId": "wamid.HBgMXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
      "phoneNumberId": "4d8e654e-d744-4bf3-8211-9ceffcccfe87"
    },
    "createdAt": "2026-11-12T08:33:46.247Z"
  },
  "attemptsDetail": [
    {
      "attempt": 1,
      "statusCode": 200,
      "error": null,
      "durationMs": 0,
      "responseSnippet": "ok",
      "at": "2026-11-12T08:33:46.322Z"
    }
  ]
}

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:

bash
curl -X POST "$INBOX_API/webhook-deliveries/ID/replay" \
  -H "Authorization: Bearer $INBOX_KEY"
Response · 202 Accepted
{
  "id": "13980898-c6ac-4b7c-8e78-f68fd741cfb9",
  "endpointId": "bc06ae32-7564-46d5-89fb-a9869019aa8e",
  "eventId": "2cbc3e2b-fba4-4246-868d-76ffe9fcb829",
  "eventType": "message.sent",
  "status": "pending",
  "attempts": 0,
  "lastStatusCode": null,
  "lastError": null,
  "replayOf": "473dfe39-7555-4942-8a5b-d2ec99026b44",
  "createdAt": "2026-11-12T08:33:46.391Z",
  "deliveredAt": null
}

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.