Business Box Start free

Webhooks

We POST to your URL the moment a booking changes, signed so you can prove it came from us.

In shortSubscribe with POST https://api.marubox.jp/v1/webhooks. Each delivery carries a BB-Signature header you verify with the secret returned at subscribe time — the scheme is Stripe's, so an existing verifier will work.

The events#

EventWhen
booking.createdA booking became confirmed for the first time. For a paid event type this is after payment succeeds, not when the booking was started.
booking.rescheduledA booking moved. Exactly one event — never a cancelled/created pair — and the payload carries the previous time.
booking.cancelledCancelled by the customer, by you, or by the system when payment was not completed.
booking.no_showYou marked the booking as a no-show.

Subscribe to "*" to receive everything, including events added later. That is the right choice for a general integration; name the events if you want only some.

Subscribing#

curl -s -X POST $BB/webhooks \
  -H "Authorization: Bearer $BB_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/hooks/business-box",
    "events": ["*"],
    "description": "Our CRM"
  }'
{
  "id": "6abc…",
  "url": "https://example.com/hooks/business-box",
  "events": ["*"],
  "secret": "whsec_…",
  "status": "active"
}

The secret appears in this response and nowhere else. Store it before you do anything else — without it you cannot verify a delivery, and the only remedy is to delete the subscription and make a new one.

The response also carries a Location header, which some connector platforms require in order to treat the subscribe as successful.

Your URL must be https and must resolve to a public address. Private and reserved ranges are refused when you subscribe, and checked again before every single delivery — DNS can be repointed at an internal address the day after a harmless hostname was accepted, and the connection is pinned to the address that was approved rather than resolved a second time.

What a delivery looks like#

POST /hooks/business-box HTTP/1.1
Content-Type: application/json
User-Agent: BusinessBox-Webhooks/1
BB-Event-Id: evt_1a2b3c4d
BB-Event-Type: booking.created
BB-Signature: t=1764200000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

{
  "id": "evt_1a2b3c4d",
  "type": "booking.created",
  "createdAt": "2026-11-27T00:00:00.000Z",
  "apiVersion": "v1",
  "data": { "booking": { "…": "the whole booking object" } }
}

The full payload shape for each event is in the reference. data.booking is the same object GET /bookings/{id} returns, so you can map it once and use it for both.

Verifying the signature#

Anyone can POST to your URL. The signature is how you know a delivery is ours.

The header is t=<unix seconds>,v1=<hex>, where the hex is HMAC-SHA256 over the exact string "<t>.<raw body>" using your subscription secret. This is deliberately the scheme Stripe uses: if you already verify Stripe webhooks, you already have this code.

Three things to get right, and they are the three things people get wrong:

  • Use the raw body, exactly as received. Parsing JSON and re-serialising it changes the bytes and the signature will not match.
  • Compare in constant time. === on a MAC leaks information through timing; use timingSafeEqual or compare_digest.
  • Check the timestamp and reject anything older than about five minutes, so a captured delivery cannot be replayed at you later.

Node#

import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';

const app = express();
const SECRET = process.env.BB_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;

// The raw body, not express.json(): the signature is over the bytes we received.
app.post('/hooks/business-box', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('BB-Signature') ?? '';
  const parts = new Map(header.split(',').map((p) => p.split('=')));
  const t = Number(parts.get('t'));
  const v1 = parts.get('v1') ?? '';

  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) {
    return res.status(400).send('stale timestamp');
  }

  const expected = createHmac('sha256', SECRET)
    .update(t + '.' + req.body.toString('utf8'))
    .digest('hex');

  const a = Buffer.from(v1, 'hex');
  const b = Buffer.from(expected, 'hex');
  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    return res.status(400).send('bad signature');
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Answer first, work afterwards: see "Answering" below.
  res.status(200).end();
  handle(event).catch((err) => console.error('webhook handler failed', err));
});

Python#

import hashlib
import hmac
import os
import time

from flask import Flask, request

app = Flask(__name__)
SECRET = os.environ["BB_WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 300


@app.post("/hooks/business-box")
def business_box_webhook():
    header = request.headers.get("BB-Signature", "")
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    try:
        t = int(parts["t"])
    except (KeyError, ValueError):
        return "malformed signature", 400

    if abs(time.time() - t) > TOLERANCE_SECONDS:
        return "stale timestamp", 400

    # request.get_data() is the raw body. request.json would re-serialise it.
    signed = f"{t}.".encode() + request.get_data()
    expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(parts.get("v1", ""), expected):
        return "bad signature", 400

    event = request.get_json()
    enqueue(event)   # answer fast; do the work elsewhere
    return "", 200

Answering, retries and back-off#

Return any 2xx as soon as you have the event safely stored. Do the real work afterwards, from a queue. A handler that writes to a CRM before replying will eventually time out, and a timeout is a failure as far as we can tell, so you will be sent the event again.

  • Timeout is 10 seconds.
  • Retries are handled by the job queue's back-off. Deliveries can therefore arrive out of order, and more than once.
  • Be idempotent. BB-Event-Id is stable across retries — record the ones you have processed and ignore repeats.
  • Redirects are not followed. Give us the final URL.

Stopping#

Two ways, and both are honoured immediately.

Delete the subscription:

curl -s -X DELETE $BB/webhooks/SUBSCRIPTION_ID -H "Authorization: Bearer $BB_KEY"

Or answer a delivery with 410 Gone, which deletes the subscription on the spot. That is the REST-hook convention Zapier and others rely on when a user removes a Zap, and it means a dead endpoint cleans itself up.

When an endpoint stops answering#

An endpoint that fails for twenty consecutive deliveries spanning at least three days is switched off, and we email you. Both conditions are needed, so an afternoon of downtime never costs you an integration.

A switched-off subscription is visible in the app under Settings → API keys and webhooks, along with the status code or error from the last attempt, so you can tell a 404 from a timeout. Fix the endpoint and switch it back on from the same screen.

Common questions

My signature never matches. What am I doing wrong?

Almost always the body. The MAC is over the raw bytes we sent, so any middleware that parses JSON and hands your handler a re-serialised string will break it. Capture the raw body before parsing.

Can I receive webhooks on http, or on localhost, while developing?

No — https and a public address are required, so use a tunnel such as ngrok or Cloudflare Tunnel. The restriction is what stops a webhook URL being used to make our server call things inside our own network.

Does a paid booking fire booking.created before or after payment?

After. The event means a confirmed booking exists, and until the payment succeeds it does not — otherwise every downstream integration would act on bookings that were never paid for.

Will a reschedule send me a cancellation and a new booking?

No. A reschedule is one booking.rescheduled event, carrying the previous start and end alongside the new ones. The booking keeps its id.

How many subscriptions can one account have?

Twenty-five, which is far more than anyone has needed.

Last reviewed 28 September 2026.