Webhooks and signature verification
Receive every new review, private feedback or customer on your server, verify the Standard Webhooks signature, and handle retries, auto-disabling and replays.
API documentationGuides · Webhooks
How it works
A webhook sends your server, as an HTTPS POST, every event of the account you subscribed to: a new review, a published reply, a private feedback, a new customer. Your tool no longer needs to poll the API to know what changed.
- An endpoint receives the events of every location of the account, from its creation onwards.
- Every delivery is signed following the open Standard Webhooks specification, whose official libraries verify the signature with no specific code.
- Delivery is guaranteed at least once, without ordering guarantee, and retried for nearly three days on failure.
- Webhooks are in beta and part of the Pro plan, like the rest of the API.
Events
| Type | When | Object delivered in data.object |
|---|---|---|
review.created | A new review comes in on a location. | The review (Review) |
review.updated | Its author changes the rating or the text. | The review |
review.deleted | The review disappears from the platform. | The review |
review.reply_published | A reply is published, from the app, the autopilot, the API or directly on Google. | The review, with its reply |
feedback.created | A customer leaves a private feedback through a QR code. | The private feedback (Feedback) |
contact.created | A customer joins the customer base. | The contact, without the phone (EventContact) |
contact.updated | Their details or consents change. | The contact |
contact.unsubscribed | The customer withdraws their email or SMS consent, including by STOP or an unsubscribe link. | The contact |
webhook.test | You ask for a test event. It cannot be subscribed to and never appears in /v1/events. | { "object": "webhook_endpoint", "id": "..." } |
A single fact produces a single event, whatever the path that caused it: a reply published from the app and then read again during the sync with Google only gives one review.reply_published. The origin field says where the fact comes from:
origin | Meaning |
|---|---|
sync | Sync with the platform, including a reply typed directly on Google. |
import | Import of a location's review history. |
app | Action taken in the app. |
autopilot | Publication by the autopilot. |
api | Write made through the API. |
system | Automatic processing by the platform. |
The list of types and the list of origins are open: plan a default handling for a value you do not know. An endpoint only receives the types it is subscribed to.
Importing a location's history can produce thousands of review.created events with the import origin. By default, an endpoint does not receive them (skipImportEvents: true): they stay readable through GET /v1/events.
Create an endpoint
You need a public HTTPS address, on port 443 or 8443. IP addresses, internal names and private addresses are refused. An account has at most 10 endpoints, disabled ones included.
In the app
- Open Settings › Integration, API & webhooks, Webhooks tab, then Add an endpoint. Only owners and admins have access.
- Enter the receiving address and tick the subscribed events.
- Keep Ignore reviews imported during the historical backfill ticked, unless your tool must receive the whole history.
- Copy the signing secret, which starts with
whsec_: it is shown only once.
The address of an endpoint cannot be changed: to use another one, add a new endpoint, then delete the old one.
Through the API
POST /v1/webhook-endpoints creates an endpoint with a key holding the webhooks:manage scope. This is the subscription expected by automation tools that manage their own webhooks. Contact events carry personal data: subscribing to them requires the key to also hold the contacts:read scope, otherwise 403 insufficient_scope. The same rule applies to reading them through 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://tool.example.com/webhooks/mabellenote",
"eventTypes": ["review.created", "review.reply_published"],
"description": "Reviews to the CRM"
}'
{
"object": "webhook_endpoint",
"id": "wh3k6m9p2r5t8v1x4z7b0d3f6h9j2l5n",
"url": "https://tool.example.com/webhooks/mabellenote",
"description": "Reviews to the 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="
}
The secret is returned only once: store it straight away in a secrets manager. This operation does not accept an Idempotency-Key header, because a replayed response would have to keep the secret: after a network error, list the endpoints with GET /v1/webhook-endpoints before creating one again.
| Situation | Response |
|---|---|
| Non-HTTPS address, IP, internal name, private address, other port | 422 unprocessable |
| Already 10 endpoints on the account | 409 webhook_endpoint_limit_reached |
| Read-only account | 403 tenant_read_only |
GET /v1/webhook-endpoints lists every endpoint of the account, created in the app (source: "app") or through the API (source: "api"). DELETE /v1/webhook-endpoints/{endpointId} deletes one, with its delivery history.
What your server receives
Every delivery is a POST request whose body is the event, as 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": "Very slow service tonight, a shame because the food was good.",
"author": { "name": "Jean D.", "photoUrl": null },
"createdAt": "2026-10-20T18:42:11.000Z",
"reply": null
}
}
}
The example is shortened: the delivered object has exactly the shape described in the reference, frozen when the event is published. An object deleted in the meantime is delivered as { "object": "review", "id": "...", "deleted": true }.
| Header | Value |
|---|---|
webhook-id | Event identifier, equal to its id. It does not change from one retry to the next. |
webhook-timestamp | Time this attempt was sent, in seconds since January 1, 1970 (UTC). |
webhook-signature | One or more v1,<base64> signatures, separated by a space. |
User-Agent | MaBelleNote-Webhooks/1. |
Verify the signature
The signature proves that the request does come from Ma Belle Note and that its body was not altered. Verify it before any processing, and refuse the request if it fails.
It is v1, followed by the base64-encoded HMAC-SHA256 of the string <webhook-id>.<webhook-timestamp>.<body>, computed with the key obtained by base64-decoding the part of the secret after whsec_. The official Standard Webhooks libraries do this computation for you, compare signatures in constant time and refuse a timestamp more than 5 minutes away from your server's clock, which blocks the replay of an intercepted request.
The signature covers the exact body, byte for byte. Give the library the body as it arrived, before any JSON decoding: a body decoded then encoded again changes spaces or escapes, and the signature no longer matches.
Node.js
With standardwebhooks and Express (npm install standardwebhooks express), in an ES module:
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 keeps the raw body in req.body, and verify returns the decoded event once the signature is validated.
PHP
With standard-webhooks/standard-webhooks (composer require standard-webhooks/standard-webhooks, PHP 8.0 or later):
<?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);
The PHP library looks headers up in lowercase (webhook-id, webhook-timestamp, webhook-signature): array_change_key_case brings them there, whatever the case sent by your web server. verify returns the event as an associative array.
Python
With standardwebhooks and 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() returns the raw body, and the library accepts headers whatever their case.
In another language
Official libraries also exist for Go, Java, C#, Ruby, Rust and Elixir: the Standard Webhooks repository lists them. If you write the verification yourself, follow the formula above, accept the request as soon as one of the v1 signatures matches, compare in constant time and check the timestamp.
Answer fast, process later
- A delivery succeeds when your server answers with a 2xx status within 10 seconds. Any other status, a redirect (never followed), a timeout or a network error counts as a failure.
- Answer as soon as the signature is verified, then process the event separately, for instance through a queue: a long processing would make the delivery fail, and it would be retried.
- Deduplicate by
webhook-id: at-least-once delivery means the same event can arrive twice, for instance when your response got lost. Keep track of processed identifiers in your database, not only in memory as in the examples above. - Do not rely on arrival order: compare
createdAt, or read the object again through the API (GET /v1/reviews/{reviewId}) when only its latest state matters.
Test the reception
The test event checks end to end that your server receives the delivery and validates its signature.
- In the app: endpoint menu, Send a test event.
- Through the API:
POST /v1/webhook-endpoints/{endpointId}/test, which immediately sends awebhook.testevent signed like a real delivery and returns the result.
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 | Your server answered with a non-2xx status, given by status. |
redirect | Your server answered with a redirect, which is not followed. |
timeout | No response within 10 seconds. |
network | Connection impossible: unknown name, refused certificate, etc. |
blocked_destination | The address resolves to a refused address, for instance private. |
The test event is not retried, not recorded in the deliveries and not logged in GET /v1/events.
Retries and automatic disabling
A failed delivery is retried on this schedule, counted from the previous failure:
| Attempt | Delay |
|---|---|
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 hours |
| 7 | 12 hours |
| 8 | 24 hours |
| 9 | 24 hours |
After the ninth attempt, nearly 70 hours after the first one, the delivery is abandoned. The deliveries of one endpoint go out one after the other: a failing endpoint does not delay the others.
When an endpoint has had no successful delivery for 72 hours and has failed at least 9 times in a row, it is automatically disabled (status: "disabled_auto"). The account's owners and admins are notified by email, and new events are no longer sent to it.
When the account leaves the Pro plan, deliveries are paused without deleting anything: events keep being logged and deliveries resume when the account returns to the Pro plan. Those left pending for more than 72 hours are then abandoned.
Replay deliveries
Replays happen in the app, Webhooks tab, where each endpoint keeps its deliveries from the last 30 days with their status, attempts and last code received:
- One delivery: View deliveries, then Replay on the delivery you want.
- After an outage: for an automatically disabled endpoint, Reactivate and replay missed deliveries reactivates it and sends again the deliveries abandoned during the outage.
A disabled endpoint must be reactivated before any replay. A replay sends the same payload, with the same webhook-id: your deduplication applies.
To catch up on events from your tool, without going through the app, read the event log: GET /v1/events?after=<position> returns every event of the account from the last 30 days, in publication order, with the same payload as webhooks (events:read scope). The pagination and sync guide details how to use it.
Rotate the secret
In the endpoint menu, Rotate the secret creates a new secret, shown only once. The previous one stays valid for 24 hours: in the meantime, each delivery carries two signatures in webhook-signature, one per secret.
- Rotate the secret and copy the new one.
- Deploy it in your tool, within 24 hours.
- Standard Webhooks libraries accept the delivery as soon as one of the signatures matches: nothing else to change.
Rotate the secret as soon as you suspect a leak, or when someone who had access to it leaves your team.
Updated on October 25, 2026

