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

TypeWhenObject delivered in data.object
review.createdA new review comes in on a location.The review (Review)
review.updatedIts author changes the rating or the text.The review
review.deletedThe review disappears from the platform.The review
review.reply_publishedA reply is published, from the app, the autopilot, the API or directly on Google.The review, with its reply
feedback.createdA customer leaves a private feedback through a QR code.The private feedback (Feedback)
contact.createdA customer joins the customer base.The contact, without the phone (EventContact)
contact.updatedTheir details or consents change.The contact
contact.unsubscribedThe customer withdraws their email or SMS consent, including by STOP or an unsubscribe link.The contact
webhook.testYou 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:

originMeaning
syncSync with the platform, including a reply typed directly on Google.
importImport of a location's review history.
appAction taken in the app.
autopilotPublication by the autopilot.
apiWrite made through the API.
systemAutomatic 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

  1. Open Settings › Integration, API & webhooks, Webhooks tab, then Add an endpoint. Only owners and admins have access.
  2. Enter the receiving address and tick the subscribed events.
  3. Keep Ignore reviews imported during the historical backfill ticked, unless your tool must receive the whole history.
  4. 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.

SituationResponse
Non-HTTPS address, IP, internal name, private address, other port422 unprocessable
Already 10 endpoints on the account409 webhook_endpoint_limit_reached
Read-only account403 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 }.

HeaderValue
webhook-idEvent identifier, equal to its id. It does not change from one retry to the next.
webhook-timestampTime this attempt was sent, in seconds since January 1, 1970 (UTC).
webhook-signatureOne or more v1,<base64> signatures, separated by a space.
User-AgentMaBelleNote-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.

Verify the raw body

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 a webhook.test event 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
}
errorKindCause
http_statusYour server answered with a non-2xx status, given by status.
redirectYour server answered with a redirect, which is not followed.
timeoutNo response within 10 seconds.
networkConnection impossible: unknown name, refused certificate, etc.
blocked_destinationThe 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:

AttemptDelay
21 minute
35 minutes
430 minutes
52 hours
66 hours
712 hours
824 hours
924 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.

  1. Rotate the secret and copy the new one.
  2. Deploy it in your tool, within 24 hours.
  3. 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