Webhooks

Booker tells your CRM or project tool the moment a booking changes. Register an endpoint, and every appointment that is booked, moved or cancelled, and every scheduling request that runs out, arrives as a signed JSON event with everything you need to update your own record.

Setting up an endpoint

You need the admin role (or an API key). Register an https URL and the events it wants:

curl -X POST https://api.example.com/api/v1/webhook-endpoints \
  -H "Authorization: Bearer $BOOKER_API_KEY" -H "Content-Type: application/json" \
  -d '{ "url": "https://crm.example.com/hooks/booker", "events": ["appointment.created", "appointment.rescheduled", "appointment.cancelled", "scheduling_request.expired"] }'

{ "success": true, "data": { "id": 5, "url": "https://crm.example.com/hooks/booker", …, "secret": "whsec_kQ3…" } }

Store the secret now: it is shown only here and when you rotate it (POST /webhook-endpoints/5/rotate-secret, which stops the old one at once). A company can have up to 3 endpoints. Add "appointment_type_ids": [4] to send an endpoint only some appointment types' events; leave it out (or []) for every type. The URL must be https and can't point at a private or local address.

Check it works with POST /webhook-endpoints/5/test, which sends a webhook.test event to that endpoint only.

The events

  • appointment.created: an appointment was booked, from any source.
  • appointment.rescheduled: it moved; data.previous holds the old times.
  • appointment.cancelled: it was cancelled; the appointment is as it was, and data.scheduling_request.status is the request's status after the cancel (usually open again).
  • scheduling_request.expired: a request passed its latest_date unbooked. Booker notices within a day.

Every event is a full snapshot, so you can match or create your record without calling back:

{
  "id": "evt_8sJ2kQ3vR7mXw1Lb9TzYc4Nd",
  "type": "appointment.created",
  "version": 1,
  "occurred_at": "2030-01-07T17:00:05.123Z",
  "company_id": 12,
  "source": { "kind": "api_key", "api_key_id": 3 },
  "data": {
    "appointment": { "id": 501, "start_time": "2030-01-07T17:00:00.000Z", "end_time": "2030-01-07T18:00:00.000Z", "timezone": "America/Denver",
      "blocks": [ … ], "appointment_type": { "id": 4, "name": "Site Survey" }, "duration_option": null,
      "provider": { "id": 7, "name": "Pat Provider" }, "location": { "id": 2, "name": "Draper Area" }, "booked_by": "link" },
    "customer": { "id": 88, "external_ref": "hubspot-contact-1234", "name": "Casey Customer", "phone": "+18015550123",
      "email": "[email protected]", "address": { "line1": "100 Demo Way", "line2": null, "city": "Draper", "state": "UT", "postal_code": "84020", "country": "US" } },
    "scheduling_request": { "id": 31, "external_ref": "deal-5521", "status": "scheduled", "earliest_date": "2030-01-06", "latest_date": "2030-01-20" }
  }
}

Every field is in the API reference under Webhooks. Within version: 1 fields may be added, never removed or changed in meaning: ignore fields you don't know.

Verifying signatures

Booker signs with Standard Webhooks. Each request carries webhook-id (the event id), webhook-timestamp (Unix seconds) and webhook-signature (v1, and a base64 HMAC-SHA256 of <id>.<timestamp>.<body>, keyed by the secret's bytes after whsec_). Verify the raw body, before parsing it, and reject timestamps more than 5 minutes off. The standardwebhooks libraries (npm and PyPI) do all of this: new Webhook(secret).verify(body, headers).

Node, by hand:

import crypto from 'node:crypto';
import express from 'express';

const key = Buffer.from(process.env.BOOKER_WEBHOOK_SECRET.slice('whsec_'.length), 'base64');

app.post('/hooks/booker', express.raw({ type: 'application/json' }), (req, res) => {
  const id = req.get('webhook-id');
  const timestamp = req.get('webhook-timestamp');
  if (!(Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300)) return res.sendStatus(400);
  const expected = crypto.createHmac('sha256', key).update(`${id}.${timestamp}.${req.body}`).digest();
  const valid = (req.get('webhook-signature') ?? '').split(' ').some((entry) => {
    const [version, value = ''] = entry.split(',');
    const given = Buffer.from(value, 'base64');
    return version === 'v1' && given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
  if (!valid) return res.sendStatus(400);
  res.sendStatus(204);                       // answer first…
  jobs.enqueue(id, JSON.parse(req.body));    // …then do the work
});

Python:

import base64, hashlib, hmac, time

def verify(secret: str, headers, body: bytes) -> bool:
    key = base64.b64decode(secret.removeprefix("whsec_"))
    msg_id, timestamp = headers.get("webhook-id"), headers.get("webhook-timestamp")
    try:
        if abs(time.time() - int(timestamp)) > 300:
            return False
    except (TypeError, ValueError):
        return False  # missing or non-numeric timestamp
    if not msg_id:
        return False
    signed = f"{msg_id}.{timestamp}.".encode() + body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    return any(
        part.startswith("v1,") and hmac.compare_digest(part[3:], expected)
        for part in headers["webhook-signature"].split(" ")
    )

Answering, de-duplicating and retries

  • Answer 2xx quickly and do the work afterwards: Booker waits 10 seconds for the whole exchange, doesn't follow redirects, and reads none of your response body.
  • De-duplicate by webhook-id. Delivery is at least once, and an event can arrive twice (after a timeout, or when you redeliver it). Events can also arrive out of order: order by occurred_at.
  • Retries: any other status, a timeout or a connection error is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours; after the 7th attempt the delivery is abandoned.
  • 410 Gone switches the endpoint off at once. An endpoint still failing 3 days after its first failure is switched off too. GET /webhook-endpoints/5 shows disabled_reason (gone or failing); turn it back on with PUT /webhook-endpoints/5 and { "enabled": true }. Test events still go to a switched-off endpoint, so you can check a fix first.
  • The delivery log, GET /webhook-endpoints/5/deliveries, shows the last 30 days (status, attempts, your last status code or the error). Open one with …/deliveries/{id} to see the payload, and POST …/deliveries/{id}/redeliver to send it again.

Matching your records

Set your own ids on Booker's records and they come back in every event: external_ref on a scheduling request (your deal or work order) and on a customer (your contact), with POST or PUT /customers. A customer's external_ref is unique within your company, and GET /customers?external_ref=… finds them. Customers your agent or the public page created have none until you set it.

Recognising your own changes

source says who made the change: agent, link (a booking link), public (your public booking page), api_key (with api_key_id), admin_user (with user_id) or system (Booker expiring a request). If your system books through the API, skip events whose source.api_key_id is your own key: they are the echo of your own change.

Let’s build the booking layer.

This local preview doesn’t submit access requests yet. The signup destination will be connected in a later version.