Data & API

Webhooks

Receive signed JSON webhooks for new and failed bookings, verify BookingsXP-Signature, handle retries, and post alerts to Slack or Microsoft Teams.

Webhooks send each booking made through a BookingsXP widget to a URL you choose: your backend, a CRM, Zapier, Make, n8n, Power Automate, or straight into a Slack or Microsoft Teams channel. Microsoft Bookings still holds the appointment and sends the invites; the webhook is a copy for your other systems.

Webhooks are on Pro (5 endpoints) and Business (25 endpoints). Slack and Teams destinations count as endpoints. Add them in the dashboard under Integrations.

Before you start: turn on Store bookings#

A booking.created webhook carries the customer's name, email, phone, notes and answers. BookingsXP stores no personal data unless you ask it to, so this event is sent only for widgets with Store bookings in BookingsXP switched on (widget → Data & access). It is off by default.

In full, a booking.created webhook is sent when all of these are true:

  • The booking came through a saved widget (widget="w_…"), not a link-only embed.
  • That widget has Store bookings on.
  • Your plan includes webhooks (Pro or Business).
  • An active endpoint subscribes to booking.created, for all widgets or for this widget.

booking.failed does not need Store bookings, because it carries no customer details.

Events#

EventWhenCustomer data
booking.createdMicrosoft Bookings has confirmed an appointment made through the widget.Yes
booking.failedMicrosoft Bookings rejected or could not create a booking the visitor submitted. Not sent when the slot was simply taken by someone else, or when the visitor's input was invalid; the widget handles those.No (customer is null)
pingYou pressed Test on an endpoint in the dashboard.No

Each endpoint subscribes to one or both booking events, and can be limited to a single widget.

Payload#

Every generic webhook is a POST with a JSON body in the same envelope:

TypeScript
type WebhookEnvelope = {
  id: string;            // "evt_…", the same for every retry and every endpoint
  type: "booking.created" | "booking.failed" | "ping";
  created: string;       // ISO 8601
  apiVersion: "2026-09-01";
  data: BookingWebhookData | { message: string };  // { message } for ping
};

booking.created#

JSON
{
  "id": "evt_3f9a1c2b7d8e4f60a1b2c3d4e5f60718",
  "type": "booking.created",
  "created": "2026-09-24T10:15:32.418Z",
  "apiVersion": "2026-09-01",
  "data": {
    "booking": {
      "reference": "BXP-7K3M9Q",
      "id": "AAMkAGI2TG93AAA=",
      "status": "confirmed",
      "start": "2026-10-02T14:00:00.000Z",
      "end": "2026-10-02T14:45:00.000Z",
      "timeZone": "Europe/London",
      "service": { "id": "a1b2c3", "name": "Initial consultation" },
      "staff": [{ "id": "d4e5f6", "name": "Sam Lee" }],
      "customer": {
        "name": "Alex Morgan",
        "email": "alex@example.com",
        "phone": "+44 20 7946 0000",
        "notes": "Knee injury from running."
      },
      "answers": [
        { "questionId": "q1", "question": "Is this your first visit?", "answer": "Yes" }
      ],
      "manageUrl": "https://outlook.office.com/book/…",
      "joinUrl": null
    },
    "business": { "id": "ContosoPhysio@contoso.com", "name": "Contoso Physio" },
    "widget": { "id": "w_8fk2m1qz", "name": "Website – main" },
    "attribution": {
      "source": "google",
      "medium": "cpc",
      "campaign": "brand",
      "channel": "Paid search",
      "landingPage": "/pricing?utm_source=google&utm_medium=cpc&utm_campaign=brand&gclid=Cj0KCQ…",
      "referrer": "https://www.google.com/",
      "pageUrl": "https://www.example.com/book",
      "utm": { "source": "google", "medium": "cpc", "campaign": "brand", "term": "physio near me" },
      "clickIds": { "gclid": "Cj0KCQ…" },
      "gaClientId": "1234567890.1727172000"
    }
  }
}
FieldNotes
booking.referenceThe BXP-XXXXXX reference. It is also in the booking notes in Microsoft Bookings and is the transaction_id for ad conversions, so use it to join records.
booking.idMicrosoft's ID for the appointment. null on booking.failed.
booking.statusconfirmed or failed.
booking.start, endISO 8601 in UTC. timeZone is the visitor's IANA time zone.
booking.staffArray; empty when no staff member was assigned.
booking.answersAnswers to the custom questions on your Bookings page. answer is a string, or an array for multiple choice.
booking.manageUrl, joinUrlLinks from Microsoft to manage the booking or join the online meeting, when available.
booking.error{ code, message }, only on booking.failed.
attribution.channelOne of Paid search, Paid social, Organic search, Organic social, Email, Referral, Direct, Other.
attribution.utmOnly the UTM keys that were present: source, medium, campaign, term, content.
attribution.clickIdsOnly the click IDs that were present: gclid, gbraid, wbraid, fbclid, msclkid, li_fat_id, ttclid.
attribution.gaClientIdFrom the _ga cookie, for joining with GA4 data. null if absent.

When the visitor has Global Privacy Control on, or your site has not given consent, the attribution fields are mostly empty. See Privacy & data.

booking.failed#

Same shape, with status: "failed", id: null, customer: null, answers: [], and an error:

JSON
{
  "booking": {
    "reference": "BXP-Q2W8ZT",
    "id": null,
    "status": "failed",
    "customer": null,
    "answers": [],
    "error": { "code": "upstream", "message": "…" }
  }
}

(Other fields shortened.) error.message is the reason reported when the booking was attempted.

ping#

JSON
{
  "id": "evt_9c1d…",
  "type": "ping",
  "created": "2026-09-24T10:20:00.000Z",
  "apiVersion": "2026-09-01",
  "data": { "message": "Test notification from BookingsXP" }
}

Headers#

HeaderValue
Content-Typeapplication/json
User-AgentBookingsXP-Webhooks/1.0 (+https://bookingsxp.com/docs/webhooks)
BookingsXP-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>
BookingsXP-EventThe event type, for example booking.created
BookingsXP-DeliveryID of this delivery. The same across retries of one delivery.

The signature and BookingsXP-* headers are sent to generic webhook endpoints. Slack and Teams endpoints receive their own message format and are not signed.

Verify the signature#

Each endpoint has a signing secret starting with whsec_, shown when you create it. The signature is an HMAC-SHA256, keyed with that secret, of the timestamp, a full stop, and the raw request body:

Text
v1 = hex( HMAC_SHA256( secret, "<t>.<raw body>" ) )

Verify against the raw bytes you received. Parsing the JSON and serialising it again changes the bytes and breaks the check. Reject timestamps more than five minutes old to stop replays.

Node.js#

JavaScript
import crypto from "node:crypto";
import express from "express";

const SECRET = process.env.BOOKINGSXP_WEBHOOK_SECRET; // whsec_…

export function verify(rawBody, header, secret) {
  if (!header) return false;
  const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  if (!t || !v1) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // replay window
  const mac = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const a = Buffer.from(mac);
  const b = Buffer.from(v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

app.post("/webhooks/bookingsxp", express.raw({ type: "application/json" }), (req, res) => {
  const raw = req.body.toString("utf8");
  if (!verify(raw, req.get("BookingsXP-Signature"), SECRET)) return res.sendStatus(400);

  const event = JSON.parse(raw);
  if (event.type === "booking.created") {
    const { reference, start, service, customer } = event.data.booking;
    // Upsert by reference so a retried delivery does not create a duplicate.
    console.log(`${reference}: ${customer.name} booked ${service.name} at ${start}`);
  }
  res.sendStatus(204);
});

app.listen(3000);

In a Next.js route handler, read the body with await request.text() and pass that string to verify.

Python#

python
import hashlib
import hmac
import json
import os
import time

from flask import Flask, abort, request

SECRET = os.environ["BOOKINGSXP_WEBHOOK_SECRET"]  # whsec_…
app = Flask(__name__)


def verify(raw_body: bytes, header: str, secret: str) -> bool:
    try:
        parts = dict(p.split("=", 1) for p in header.split(","))
        t, v1 = parts["t"], parts["v1"]
    except (AttributeError, KeyError, ValueError):
        return False
    if abs(time.time() - int(t)) > 300:
        return False
    signed = f"{t}.".encode() + raw_body
    mac = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(mac, v1)


@app.post("/webhooks/bookingsxp")
def bookingsxp_webhook():
    raw = request.get_data()
    if not verify(raw, request.headers.get("BookingsXP-Signature", ""), SECRET):
        abort(400)
    event = json.loads(raw)
    if event["type"] == "booking.created":
        booking = event["data"]["booking"]
        print(booking["reference"], booking["customer"]["email"])
    return "", 204

Responding, retries and duplicates#

  • Reply with any 2xx status within 10 seconds. Do slow work (CRM calls, emails) after replying, or in a queue.
  • Anything else counts as a failure: a non-2xx status, a timeout, a connection error, or a redirect. Redirects are not followed, so give the final URL.
  • Failed deliveries are retried up to 8 more times with exponential backoff starting at 30 seconds: about 30 s, 1 min, 2 min, 4 min, 8 min, 16 min, 32 min and 64 min. That is about two hours in total. After the last attempt the delivery is marked failed.
  • A delivery can arrive more than once, for example if your server finished the work but replied after the timeout. Make your handler idempotent: key on data.booking.reference or the envelope id.
  • Deliveries are independent, so do not rely on their order.
  • Endpoint URLs must be public http(s) addresses. Private and internal network addresses are rejected.

Delivery log and testing#

Integrations → Recent deliveries lists each delivery with the event, the HTTP status or error, and the number of attempts. Any delivery that has not succeeded has a Retry button to send it again now. The log keeps 30 days.

Test on an endpoint sends a ping straight away and shows what your server answered (status, time and the start of the response body). Test pings are sent once, without retries, and are not added to the log.

Slack#

  1. In Slack, create an app (or open an existing one), turn on Incoming Webhooks, and add a webhook to the channel you want. The URL starts with https://hooks.slack.com/.
  2. In BookingsXP, add an endpoint of type Slack and paste the URL.

Each booking posts a message with the service, time, staff, source and campaign, customer name and email, and the reference:

Text
New booking: Alex Morgan · Initial consultation · Fri, Oct 2, 3:00 PM BST
Service: Initial consultation        When: Fri, Oct 2, 3:00 PM BST
With: Sam Lee                        Source: google / cpc · brand
Customer: Alex Morgan <alex@example.com>
Ref: BXP-7K3M9Q
via Website – main · Contoso Physio

Microsoft Teams#

Teams uses Workflows (Power Automate) for incoming webhooks.

  1. In the Teams channel, open Workflows and choose the template Post to a channel when a webhook request is received.
  2. Pick the team and channel, finish the setup, and copy the URL it gives you.
  3. In BookingsXP, add an endpoint of type Microsoft Teams and paste the URL.

BookingsXP sends an Adaptive Card (version 1.4) with a title line and facts for the service, time, staff, source, customer and reference. The body is a standard Teams message, so the Workflows template posts it without changes:

JSON
{
  "type": "message",
  "attachments": [
    {
      "contentType": "application/vnd.microsoft.card.adaptive",
      "content": {
        "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
        "type": "AdaptiveCard",
        "version": "1.4",
        "body": [
          { "type": "TextBlock", "text": "New booking: Alex Morgan · Initial consultation · Fri, Oct 2, 3:00 PM BST", "weight": "Bolder", "size": "Medium", "wrap": true },
          { "type": "FactSet", "facts": [
            { "title": "Service", "value": "Initial consultation" },
            { "title": "When", "value": "Fri, Oct 2, 3:00 PM BST" },
            { "title": "Source", "value": "google / cpc · brand" },
            { "title": "Reference", "value": "BXP-7K3M9Q" }
          ] }
        ]
      }
    }
  ]
}

A booking.failed alert reads "Booking failed on (widget name): (reason)", which is useful for spotting a Bookings page that has stopped accepting bookings.

Zapier, Make, n8n and Power Automate#

Use an endpoint of type Webhook (JSON) with the URL each tool gives you. They receive the full JSON payload above.

ToolTrigger to use
ZapierWebhooks by Zapier → Catch Hook
MakeWebhooks → Custom webhook
n8nWebhook node, method POST
Power AutomateWhen an HTTP request is received

Press Test in BookingsXP after pasting the URL so the tool sees a sample request. The ping body is small; to map booking fields, make one real test booking and use that request as the sample. These tools do not check the signature by default. Treat the URL as a secret, or verify the signature in a code step with the examples above.

Edit or question? hello@bookingsxp.com