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#
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"
]
}'const res = await fetch(`${process.env.INBOX_API}/webhook-endpoints`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.INBOX_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"url": "https://api.example.com/webhooks/inbox",
"events": [
"message.received",
"message.delivered",
"message.failed"
]
}),
});
const data = await res.json();import os
import requests
res = requests.post(
f"{os.environ['INBOX_API']}/webhook-endpoints",
headers={
"Authorization": f"Bearer {os.environ['INBOX_KEY']}",
},
json={
"url": "https://api.example.com/webhooks/inbox",
"events": [
"message.received",
"message.delivered",
"message.failed",
],
},
)
print(res.json())<?php
$ch = curl_init(getenv('INBOX_API') . '/webhook-endpoints');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('INBOX_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'url' => 'https://api.example.com/webhooks/inbox',
'events' => [
'message.received',
'message.delivered',
'message.failed',
],
]),
]);
$data = json_decode(curl_exec($ch), true);{
"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#
curl -X POST "$INBOX_API/webhook-endpoints/ID/test" \
-H "Authorization: Bearer $INBOX_KEY"const res = await fetch(`${process.env.INBOX_API}/webhook-endpoints/ID/test`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.INBOX_KEY}`,
},
});
const data = await res.json();import os
import requests
res = requests.post(
f"{os.environ['INBOX_API']}/webhook-endpoints/ID/test",
headers={
"Authorization": f"Bearer {os.environ['INBOX_KEY']}",
},
)
print(res.json())<?php
$ch = curl_init(getenv('INBOX_API') . '/webhook-endpoints/ID/test');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('INBOX_KEY'),
],
]);
$data = json_decode(curl_exec($ch), true);{
"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#
| 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#
{
"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.
// 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
// });# Verifying a Bref Point Inbox webhook — Python 3, no dependencies.
# BrefPoint-Signature header: t=<seconds>,v1=<hmac-sha256-hex>; the signature covers "<t>.<raw body>".
import hashlib
import hmac
import time
def verify_webhook(raw_body: bytes, header: str, secret: str, tolerance_seconds: int = 300) -> bool:
"""raw_body: the EXACT body received (bytes), before any json.loads."""
try:
parts = dict(p.split("=", 1) for p in header.split(","))
t = int(parts["t"])
v1 = parts["v1"]
except (KeyError, ValueError, AttributeError):
return False
if abs(time.time() - t) > tolerance_seconds: # envoi trop ancien : rejeu possible
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
# Exemple avec Flask :
# @app.post("/webhooks/inbox")
# def inbox_webhook():
# if not verify_webhook(request.get_data(), request.headers.get("BrefPoint-Signature", ""), SECRET):
# return "", 401
# event = request.get_json() # { id, type, createdAt, data }
# # event["id"] is stable: ignore it if already processed (retry, replay)
# return "", 200 # answer fast (2xx), process afterwards<?php
// Verifying a Bref Point Inbox webhook — PHP 7.4+, no dependencies.
// BrefPoint-Signature header: t=<seconds>,v1=<hmac-sha256-hex>; the signature covers "<t>.<raw body>".
/**
* @param string $rawBody the EXACT body received: file_get_contents('php://input'), before json_decode
* @param string|null $header value of the BrefPoint-Signature header
* @param string $secret the endpoint's whsec_… secret
*/
function verifyWebhook(string $rawBody, ?string $header, string $secret, int $toleranceSeconds = 300): bool
{
if (!$header) {
return false;
}
$parts = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', $part, 2), 2, '');
$parts[$key] = $value;
}
if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) {
return false;
}
if (abs(time() - (int) $parts['t']) > $toleranceSeconds) { // envoi trop ancien : rejeu possible
return false;
}
$expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1']);
}
// Utilisation :
// $raw = file_get_contents('php://input');
// if (!verifyWebhook($raw, $_SERVER['HTTP_BREFPOINT_SIGNATURE'] ?? null, getenv('INBOX_WEBHOOK_SECRET'))) {
// http_response_code(401); exit;
// }
// $event = json_decode($raw, true); // { id, type, createdAt, data } — `id` is stable: deduplicate on it
// http_response_code(200); // answer fast (2xx), process afterwardsThese 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
2xxcode (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
idis stable across attempts, so deduplicate on it. - Order is not guaranteed: a
message.readcan arrive before amessage.delivered. Rely onoccurredAt, 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 withPATCH … { "enabled": true }once your receiver is fixed.
History and replay#
Every delivery and every attempt is kept (according to your plan's retention).
curl "$INBOX_API/webhook-deliveries/ID" \
-H "Authorization: Bearer $INBOX_KEY"const res = await fetch(`${process.env.INBOX_API}/webhook-deliveries/ID`, {
headers: {
Authorization: `Bearer ${process.env.INBOX_KEY}`,
},
});
const data = await res.json();import os
import requests
res = requests.get(
f"{os.environ['INBOX_API']}/webhook-deliveries/ID",
headers={
"Authorization": f"Bearer {os.environ['INBOX_KEY']}",
},
)
print(res.json())<?php
$ch = curl_init(getenv('INBOX_API') . '/webhook-deliveries/ID');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('INBOX_KEY'),
],
]);
$data = json_decode(curl_exec($ch), true);{
"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:
curl -X POST "$INBOX_API/webhook-deliveries/ID/replay" \
-H "Authorization: Bearer $INBOX_KEY"const res = await fetch(`${process.env.INBOX_API}/webhook-deliveries/ID/replay`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.INBOX_KEY}`,
},
});
const data = await res.json();import os
import requests
res = requests.post(
f"{os.environ['INBOX_API']}/webhook-deliveries/ID/replay",
headers={
"Authorization": f"Bearer {os.environ['INBOX_KEY']}",
},
)
print(res.json())<?php
$ch = curl_init(getenv('INBOX_API') . '/webhook-deliveries/ID/replay');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('INBOX_KEY'),
],
]);
$data = json_decode(curl_exec($ch), true);{
"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.