Webhooks et vérification de signature
Recevoir sur votre serveur chaque nouvel avis, retour privé ou client, vérifier la signature Standard Webhooks, puis gérer relances, désactivation et rejeu.
Documentation de l'APIGuides · Webhooks
Principe
Un webhook envoie à votre serveur, en POST HTTPS, chaque événement du compte auquel vous vous êtes abonné : un nouvel avis, une réponse publiée, un retour privé, un nouveau client. Votre outil n'a plus besoin d'interroger l'API pour savoir ce qui a changé.
- Un point de réception reçoit les événements de tous les établissements du compte, à partir de sa création.
- Chaque livraison est signée selon la spécification ouverte Standard Webhooks, dont les bibliothèques officielles vérifient la signature sans code spécifique.
- La livraison est garantie au moins une fois, sans garantie d'ordre, et relancée pendant près de trois jours en cas d'échec.
- Les webhooks sont en version bêta et font partie du plan Pro, comme le reste de l'API.
Les événements
| Type | Quand | Objet livré dans data.object |
|---|---|---|
review.created | Un nouvel avis arrive sur un établissement. | L'avis (Review) |
review.updated | Son auteur modifie la note ou le texte. | L'avis |
review.deleted | L'avis disparaît de la plateforme. | L'avis |
review.reply_published | Une réponse est publiée, depuis l'application, l'autopilote, l'API ou directement sur Google. | L'avis, avec sa réponse |
feedback.created | Un client laisse un retour privé depuis un QR code. | Le retour privé (Feedback) |
contact.created | Un client entre dans la base clients. | Le contact, sans le téléphone (EventContact) |
contact.updated | Ses coordonnées ou ses consentements changent. | Le contact |
contact.unsubscribed | Le client retire son consentement email ou SMS, y compris par STOP ou lien de désinscription. | Le contact |
webhook.test | Vous demandez un événement de test. Il ne s'abonne pas et n'apparaît jamais dans /v1/events. | { "object": "webhook_endpoint", "id": "..." } |
Un même fait ne produit qu'un seul événement, quel que soit le chemin qui l'a causé : une réponse publiée depuis l'application puis relue lors de la synchronisation avec Google ne donne qu'un review.reply_published. Le champ origin dit d'où vient le fait :
origin | Signification |
|---|---|
sync | Synchronisation avec la plateforme, y compris une réponse saisie directement sur Google. |
import | Import de l'historique des avis d'un établissement. |
app | Action faite dans l'application. |
autopilot | Publication par l'autopilote. |
api | Écriture faite par l'API. |
system | Traitement automatique de la plateforme. |
La liste des types et celle des origines sont ouvertes : prévoyez un traitement par défaut pour une valeur que vous ne connaissez pas. Un point de réception ne reçoit que les types auxquels il est abonné.
L'import de l'historique d'un établissement peut produire des milliers d'événements review.created d'origine import. Par défaut, un point de réception ne les reçoit pas (skipImportEvents: true) : ils restent lisibles par GET /v1/events.
Créer un point de réception
Il faut une adresse HTTPS publique, sur le port 443 ou 8443. Les adresses IP, les noms internes et les adresses privées sont refusés. Un compte a au plus 10 points de réception, désactivés compris.
Dans l'application
- Ouvrez Réglages › Intégration, API & webhooks, onglet Webhooks, puis Ajouter un point de réception. Seuls les propriétaires et les administrateurs y ont accès.
- Saisissez l'adresse de réception et cochez les événements suivis.
- Laissez cochée la case Ignorer les avis importés lors du rattrapage historique, sauf si votre outil doit recevoir tout l'historique.
- Copiez le secret de signature, qui commence par
whsec_: il ne s'affiche qu'une seule fois.
L'adresse d'un point de réception ne se modifie pas : pour en changer, ajoutez-en un nouveau, puis supprimez l'ancien.
Par l'API
POST /v1/webhook-endpoints crée un point de réception avec une clé qui porte le droit webhooks:manage. C'est l'abonnement qu'attendent les outils d'automatisation qui gèrent eux-mêmes leurs webhooks. Les événements de contact portent des données personnelles : s'y abonner exige que la clé porte aussi le droit contacts:read, sinon 403 insufficient_scope. La même règle vaut pour leur lecture par GET /v1/events.
curl https://api.mabellenote.com/v1/webhook-endpoints \
-H "Authorization: Bearer $MBN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://outil.exemple.com/webhooks/mabellenote",
"eventTypes": ["review.created", "review.reply_published"],
"description": "Avis vers le CRM"
}'
{
"object": "webhook_endpoint",
"id": "wh3k6m9p2r5t8v1x4z7b0d3f6h9j2l5n",
"url": "https://outil.exemple.com/webhooks/mabellenote",
"description": "Avis vers le CRM",
"eventTypes": ["review.created", "review.reply_published"],
"apiVersion": "v1",
"skipImportEvents": true,
"status": "active",
"source": "api",
"createdAt": "2026-10-25T09:30:00.000Z",
"secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSwq3Bx7zNkE1o="
}
Le secret n'est renvoyé qu'une seule fois : rangez-le aussitôt dans un gestionnaire de secrets. Cette opération n'accepte pas d'en-tête Idempotency-Key, car la réponse rejouée devrait conserver le secret : après une erreur réseau, listez les points de réception avec GET /v1/webhook-endpoints avant d'en recréer un.
| Situation | Réponse |
|---|---|
| Adresse non HTTPS, IP, nom interne, adresse privée, autre port | 422 unprocessable |
| Déjà 10 points de réception sur le compte | 409 webhook_endpoint_limit_reached |
| Compte en lecture seule | 403 tenant_read_only |
GET /v1/webhook-endpoints liste tous les points de réception du compte, créés dans l'application (source: "app") ou par l'API (source: "api"). DELETE /v1/webhook-endpoints/{endpointId} en supprime un, avec son historique de livraisons.
Ce que reçoit votre serveur
Chaque livraison est une requête POST dont le corps est l'événement, en JSON :
POST /webhooks/mabellenote HTTP/1.1
Content-Type: application/json
User-Agent: MaBelleNote-Webhooks/1
webhook-id: ev8k2m4n6p8r0t2v4x6z8b0d2f4h6j8l
webhook-timestamp: 1761384135
webhook-signature: v1,DX+qqa2n8CTLw+sUeFhKkNb/uZsBdhzeQuh1sTFzaNY=
{
"object": "event",
"id": "ev8k2m4n6p8r0t2v4x6z8b0d2f4h6j8l",
"type": "review.created",
"apiVersion": "v1",
"createdAt": "2026-10-20T18:42:15.311Z",
"establishmentId": "kh2n8p5r3t6v9b1c4d7f0g2j5k8m1n3p",
"origin": "sync",
"data": {
"object": {
"object": "review",
"id": "jd7f3k2m9x8w1q4z6c5v3b2n1m0l9k8j",
"establishmentId": "kh2n8p5r3t6v9b1c4d7f0g2j5k8m1n3p",
"platform": "google",
"rating": 2,
"comment": "Service très lent ce soir, dommage car les plats étaient bons.",
"author": { "name": "Jean D.", "photoUrl": null },
"createdAt": "2026-10-20T18:42:11.000Z",
"reply": null
}
}
}
L'exemple est abrégé : l'objet livré a exactement la forme décrite dans la référence, figée au moment où l'événement est publié. Un objet supprimé entre-temps est livré sous la forme { "object": "review", "id": "...", "deleted": true }.
| En-tête | Valeur |
|---|---|
webhook-id | Identifiant de l'événement, égal à son id. Il ne change pas d'une relance à l'autre. |
webhook-timestamp | Heure d'envoi de cette tentative, en secondes depuis le 1er janvier 1970 (UTC). |
webhook-signature | Une ou plusieurs signatures v1,<base64>, séparées par une espace. |
User-Agent | MaBelleNote-Webhooks/1. |
Vérifier la signature
La signature prouve que la requête vient bien de Ma Belle Note et que son corps n'a pas été modifié. Vérifiez-la avant tout traitement, et refusez la requête si elle échoue.
Elle vaut v1, suivi du HMAC-SHA256, encodé en base64, de la chaîne <webhook-id>.<webhook-timestamp>.<corps>, calculé avec la clé obtenue en décodant en base64 la partie du secret qui suit whsec_. Les bibliothèques officielles Standard Webhooks font ce calcul pour vous, comparent les signatures en temps constant et refusent un horodatage écarté de plus de 5 minutes de l'heure de votre serveur, ce qui bloque le rejeu d'une requête interceptée.
La signature porte sur le corps exact, octet pour octet. Passez à la bibliothèque le corps tel qu'il est arrivé, avant tout décodage JSON : un corps décodé puis réencodé change d'espaces ou d'échappements, et la signature ne correspond plus.
Node.js
Avec standardwebhooks et Express (npm install standardwebhooks express), dans un module ES :
import express from "express";
import { Webhook, WebhookVerificationError } from "standardwebhooks";
const webhook = new Webhook(process.env.MBN_WEBHOOK_SECRET);
const processed = new Set();
const app = express();
function handleEvent(event) {
if (processed.has(event.id)) return;
processed.add(event.id);
console.log(event.type, event.data.object.id);
}
app.post("/webhooks/mabellenote", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = webhook.verify(req.body, req.headers);
} catch (error) {
if (error instanceof WebhookVerificationError) return res.sendStatus(400);
throw error;
}
res.sendStatus(204);
handleEvent(event);
});
app.listen(3000);
express.raw garde le corps brut dans req.body, et verify renvoie l'événement décodé une fois la signature validée.
PHP
Avec standard-webhooks/standard-webhooks (composer require standard-webhooks/standard-webhooks, PHP 8.0 ou plus) :
<?php
require __DIR__ . '/vendor/autoload.php';
use StandardWebhooks\Exception\WebhookVerificationException;
use StandardWebhooks\Webhook;
$payload = file_get_contents('php://input');
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
try {
$event = (new Webhook(getenv('MBN_WEBHOOK_SECRET')))->verify($payload, $headers);
} catch (WebhookVerificationException $e) {
http_response_code(400);
exit;
}
file_put_contents(__DIR__ . '/events.ndjson', json_encode($event) . "\n", FILE_APPEND | LOCK_EX);
http_response_code(204);
La bibliothèque PHP cherche les en-têtes en minuscules (webhook-id, webhook-timestamp, webhook-signature) : array_change_key_case les y ramène, quelle que soit la casse transmise par votre serveur web. verify renvoie l'événement sous forme de tableau associatif.
Python
Avec standardwebhooks et Flask (pip install standardwebhooks flask) :
import os
from flask import Flask, request
from standardwebhooks import Webhook, WebhookVerificationError
app = Flask(__name__)
webhook = Webhook(os.environ["MBN_WEBHOOK_SECRET"])
processed = set()
@app.post("/webhooks/mabellenote")
def receive_event():
try:
event = webhook.verify(request.get_data(), dict(request.headers))
except WebhookVerificationError:
return "", 400
if event["id"] not in processed:
processed.add(event["id"])
print(event["type"], event["data"]["object"]["id"])
return "", 204
request.get_data() renvoie le corps brut, et la bibliothèque accepte les en-têtes quelle que soit leur casse.
Dans un autre langage
Des bibliothèques officielles existent aussi pour Go, Java, C#, Ruby, Rust et Elixir : le dépôt Standard Webhooks les liste. Si vous écrivez la vérification vous-même, suivez la formule ci-dessus, acceptez la requête dès qu'une des signatures v1 correspond, comparez en temps constant et contrôlez l'horodatage.
Répondre vite, traiter ensuite
- Une livraison réussit quand votre serveur répond par un statut 2xx en moins de 10 secondes. Tout autre statut, une redirection (jamais suivie), un délai dépassé ou une erreur réseau compte comme un échec.
- Répondez dès la signature vérifiée, puis traitez l'événement à part, par exemple dans une file : un traitement long ferait échouer la livraison, qui serait relancée.
- Dédoublonnez par
webhook-id: la livraison au moins une fois signifie qu'un même événement peut arriver deux fois, par exemple quand votre réponse s'est perdue. Gardez la trace des identifiants traités dans votre base, pas seulement en mémoire comme dans les exemples ci-dessus. - Ne vous fiez pas à l'ordre d'arrivée : comparez
createdAt, ou relisez l'objet par l'API (GET /v1/reviews/{reviewId}) quand seul son dernier état compte.
Tester la réception
L'événement de test vérifie de bout en bout que votre serveur reçoit la livraison et valide sa signature.
- Dans l'application : menu du point de réception, Envoyer un événement de test.
- Par l'API :
POST /v1/webhook-endpoints/{endpointId}/test, qui envoie aussitôt un événementwebhook.testsigné comme une vraie livraison et renvoie le résultat.
curl -X POST https://api.mabellenote.com/v1/webhook-endpoints/wh3k6m9p2r5t8v1x4z7b0d3f6h9j2l5n/test \
-H "Authorization: Bearer $MBN_API_KEY"
{
"object": "webhook_test",
"delivered": true,
"status": 200,
"errorKind": null,
"durationMs": 182
}
errorKind | Cause |
|---|---|
http_status | Votre serveur a répondu par un statut hors 2xx, donné par status. |
redirect | Votre serveur a répondu par une redirection, qui n'est pas suivie. |
timeout | Pas de réponse en 10 secondes. |
network | Connexion impossible : nom introuvable, certificat refusé, etc. |
blocked_destination | L'adresse se résout vers une adresse refusée, par exemple privée. |
L'événement de test n'est ni relancé, ni enregistré dans les livraisons, ni journalisé dans GET /v1/events.
Relances et désactivation automatique
Une livraison en échec est relancée selon ce calendrier, à compter de l'échec précédent :
| Tentative | Délai |
|---|---|
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 heures |
| 6 | 6 heures |
| 7 | 12 heures |
| 8 | 24 heures |
| 9 | 24 heures |
Après la neuvième tentative, près de 70 heures après la première, la livraison est abandonnée. Les livraisons d'un même point de réception partent l'une après l'autre : un point de réception en panne ne retarde pas les autres.
Quand un point de réception n'a reçu aucune livraison réussie pendant 72 heures et a enchaîné au moins 9 échecs, il est désactivé automatiquement (status: "disabled_auto"). Les propriétaires et les administrateurs du compte sont prévenus par email, et les nouveaux événements ne lui sont plus envoyés.
Quand le compte quitte le plan Pro, les envois sont mis en pause sans rien supprimer : les événements continuent d'être journalisés et les livraisons reprennent au retour au plan Pro. Celles restées en attente plus de 72 heures sont alors abandonnées.
Rejouer des livraisons
Le rejeu se fait dans l'application, onglet Webhooks, où chaque point de réception garde ses livraisons des 30 derniers jours avec leur état, leurs tentatives et le dernier code reçu :
- Une livraison : Voir les livraisons, puis Rejouer sur la livraison voulue.
- Après une panne : pour un point de réception désactivé automatiquement, Réactiver et rejouer les livraisons manquées le réactive et renvoie les livraisons abandonnées pendant la panne.
Un point de réception désactivé doit être réactivé avant tout rejeu. Un rejeu renvoie la même charge utile, avec le même webhook-id : votre dédoublonnage s'applique.
Pour rattraper des événements depuis votre outil, sans passer par l'application, lisez le journal d'événements : GET /v1/events?after=<position> renvoie tous les événements du compte des 30 derniers jours, dans l'ordre de leur publication, avec la même charge utile que les webhooks (droit events:read). Le guide de pagination et de synchronisation détaille son usage.
Renouveler le secret
Dans le menu du point de réception, Renouveler le secret crée un nouveau secret, affiché une seule fois. L'ancien reste valide 24 heures : pendant ce délai, chaque livraison porte deux signatures dans webhook-signature, une par secret.
- Renouvelez le secret et copiez le nouveau.
- Déployez-le dans votre outil, dans les 24 heures.
- Les bibliothèques Standard Webhooks acceptent la livraison dès qu'une des signatures correspond : rien d'autre à changer.
Renouvelez le secret dès que vous soupçonnez une fuite, ou quand une personne qui y avait accès quitte votre équipe.
Mis à jour le 25 octobre 2026

