Guides
Webhooks
Recevoir les événements en temps réel, vérifier leur signature, comprendre les reprises et rejouer une livraison.
Mis à jour le 12 novembre 2026Voir en Markdown
Les webhooks vous notifient dès qu'il se passe quelque chose : message reçu, envoyé, délivré, lu, échoué, template approuvé ou rejeté. Bref Point Inbox envoie une requête POST JSON à l'adresse que vous déclarez.
Déclarer une destination#
curl -X POST "$INBOX_API/webhook-endpoints" \
-H "Authorization: Bearer $INBOX_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.exemple.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.exemple.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.exemple.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.exemple.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.exemple.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"
}L'URL doit être en https:// et joignable publiquement. Les adresses http://, localhost, les réseaux privés et les noms internes sont refusés. Pour développer en local, exposez votre serveur par un tunnel HTTPS public.
Les destinations sont propres à un environnement : celles du sandbox ne reçoivent jamais d'événements de production.
Vérifier la destination#
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
}Nous envoyons un événement webhook.test signé comme les autres et vous renvoyons la réponse de votre serveur (ok, code HTTP, durée, ou l'erreur de connexion).
Les événements#
| Type | Quand | Contenu de data |
|---|---|---|
message.received | Un client vous écrit | messageId, waMessageId, phoneNumberId, from, type, content, contactName, occurredAt |
message.sent | Le message est remis à Meta | messageId, waMessageId, phoneNumberId, to, status, occurredAt |
message.delivered | Remis à l'appareil du destinataire | idem, plus pricing si Meta le fournit |
message.read | Lu par le destinataire | idem |
message.failed | Refusé ou non remis | messageId, phoneNumberId, to, status, occurredAt, error (code, message) |
template.status_updated | Meta approuve, rejette ou met en pause un template | templateId, wabaConnectionId, name, language, status, previousStatus, reason |
Abonnez une destination à * pour tout recevoir, ou à une liste précise. PATCH /webhook-endpoints/{id} modifie l'abonnement.
Le corps reçu#
{
"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" }
}Chaque requête porte aussi ces en-têtes :
| En-tête | Valeur |
|---|---|
BrefPoint-Signature | t=<secondes>,v1=<signature> : voir ci-dessous |
BrefPoint-Event-Type | Le type de l'événement |
BrefPoint-Delivery-Id | Identifiant de cette livraison |
Content-Type | application/json |
Vérifier la signature#
Vérifiez toujours la signature : sans elle, n'importe qui pourrait appeler votre adresse en se faisant passer pour Bref Point Inbox.
La signature v1 est le HMAC-SHA256, en hexadécimal, de la chaîne "<t>.<corps brut>", avec votre secret whsec_…. t est l'horodatage (en secondes) de l'envoi : refusez les envois trop anciens (5 minutes par défaut) pour vous protéger du rejeu.
// Vérification d'un webhook Bref Point Inbox — à copier dans votre serveur (Node.js ≥ 18, sans dépendance).
//
// L'en-tête `BrefPoint-Signature` a la forme : t=<secondes>,v1=<hmac-sha256-hex>
// La signature porte sur la chaîne "<t>.<corps brut>" avec le secret `whsec_…` de la destination.
import { createHmac, timingSafeEqual } from 'node:crypto';
/**
* @param {string|Buffer} rawBody le corps EXACT reçu (avant tout JSON.parse)
* @param {string} header valeur de l'en-tête BrefPoint-Signature
* @param {string} secret secret whsec_… de la destination
* @param {number} toleranceSeconds âge maximal accepté (protège du rejeu), 300 s par défaut
*/
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` est stable : s'il revient (nouvel essai, rejeu), ignorez-le s'il est déjà traité.
// res.sendStatus(200); // répondre vite (2xx), traiter ensuite
// });# Vérification d'un webhook Bref Point Inbox — Python 3, sans dépendance.
# En-tête BrefPoint-Signature : t=<secondes>,v1=<hmac-sha256-hex> ; la signature porte sur "<t>.<corps brut>".
import hashlib
import hmac
import time
def verify_webhook(raw_body: bytes, header: str, secret: str, tolerance_seconds: int = 300) -> bool:
"""raw_body : le corps EXACT reçu (octets), avant tout 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"] est stable : ignorez-le s'il est déjà traité (nouvel essai, rejeu)
# return "", 200 # répondre vite (2xx), traiter ensuite<?php
// Vérification d'un webhook Bref Point Inbox — PHP 7.4+, sans dépendance.
// En-tête BrefPoint-Signature : t=<secondes>,v1=<hmac-sha256-hex> ; la signature porte sur "<t>.<corps brut>".
/**
* @param string $rawBody le corps EXACT reçu : file_get_contents('php://input'), avant json_decode
* @param string|null $header valeur de l'en-tête BrefPoint-Signature
* @param string $secret secret whsec_… de la destination
*/
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` est stable : dédoublonnez dessus
// http_response_code(200); // répondre vite (2xx), traiter ensuiteCes trois exemples sont exécutés par les tests de Bref Point Inbox contre la vraie signature : ce que vous copiez ici fonctionne.
Fiabilité des livraisons#
- Répondez vite par un code
2xx(délai maximal : 10 secondes), puis traitez. Une redirection (3xx) est un échec. - Un échec est réessayé jusqu'à 8 fois au total, avec une attente qui double : environ 30 s, 1 min, 2 min, 4 min, 8 min, 16 min, puis 32 min (soit à peu près une heure).
- Les événements sont livrés au moins une fois : l'
idest stable d'une tentative à l'autre, dédoublonnez dessus. - L'ordre n'est pas garanti : un
message.readpeut précéder unmessage.delivered. Fiez-vous àoccurredAt, et ne revenez jamais en arrière sur un statut. - Après 15 livraisons définitivement échouées d'affilée, la destination est désactivée (
status: "disabled", avec la raison). Réactivez-la avecPATCH … { "enabled": true }une fois votre récepteur réparé.
Historique et rejeu#
Chaque livraison et chaque tentative sont conservées (selon la rétention de votre plan).
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 donne, pour chaque tentative, le code de réponse, la durée, l'erreur éventuelle et un extrait de la réponse de votre serveur : de quoi comprendre un échec sans deviner.
Votre récepteur était en panne ? Une fois réparé, rejouez :
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
}Le rejeu crée une nouvelle livraison du même événement (même id d'événement) vers la même destination ; replayOf pointe vers l'originale. Il est refusé (409) si la livraison est encore en cours ou si la destination est désactivée.