Build your own shop

This guide shows the API in the order that a customer uses it. The customer finds the shop, browses the catalog, checks availability, sees the price, orders, and pays. The API reference describes each request in detail. This guide shows how the requests work together. The examples use plain browser JavaScript.

A complete working shop is available: the example shop. We built it only from this guide. It uses a test shop, so you can place an order without a payment. Its source code is plain JavaScript, with no build step.

Before you start

const API = 'https://api.be-bookable.com/v1';

async function api(path, options) {
  const response = await fetch(`${API}${path}`, options);
  const body = await response.json().catch(() => null); // an error page of a proxy is not JSON
  if (!response.ok) {
    const retryAfter = Number(response.headers.get('Retry-After')) || undefined;
    throw Object.assign(new Error(body?.error?.message ?? `HTTP ${response.status}`), { status: response.status, retryAfter }, body?.error); // { status, retryAfter?, code, message, details?, request_id }
  }
  return body;
}

// Build each query string with URLSearchParams.
const query = (params) => new URLSearchParams(params).toString();

Build each query string with URLSearchParams, as in the example. A plus sign in a time offset (+02:00) is a space in a URL that is not encoded. The request then fails with 400 DATE_FORMAT_MISMATCH.

1. Find the shop

All requests start with the slug of the shop. The response has the id that most other requests need. It also has the information that a shop front needs: colors, logo, country, timezone, shipping options, and closure days.

const slug = 'my-shop'; // as in the web address of the shop
const shop = await api(`/partners/${slug}`);
// shop.id, shop.timezone, shop.country, shop.shipping_options, shop.closure_days, ...

2. Show the catalog

const categories = await api(`/partners/${shop.id}/categories`);
const products = await api(`/categories/${categories.data[0].id}/products`);
const product = await api(`/products/${products.data[0].id}`);

3. Check availability

Use one of three requests. They go from broad to precise:

You want to Request
Show only the products that are free in a chosen window GET /available-products
Show a calendar or time slots for one product GET /check-availability-for-period
Confirm one product, period, and quantity GET /check-availability
const days = await api(`/check-availability-for-period?${query({ productId: product.id, startDate: '2026-10-20', endDate: '2026-10-31' })}`);

Days. The response of check-availability-for-period has one entry for each day, with the free units in quantity. The last day is included: 2026-10-20 to 2026-10-22 is three days. The free units of the period are the lowest quantity of its days. Use the first and last day of the period as dateStart and dateEnd in the next requests.

Hours. Send the same day as startDate and endDate. The response has one entry for each start-time slot, with slot_start, slot_end, and available. The customer selects slots that are next to each other and that have enough available units. Send the slot_start of the first slot as dateStart. Send the slot_end of the last slot as dateEnd. Show the times in the timezone of the shop, not in the timezone of the visitor.

Dates. Day and week products use YYYY-MM-DD. Hourly products use an instant with an offset or Z. For example, 2026-10-20T09:00:00+02:00 is 09:00 in an Amsterdam shop. It is the same instant as 2026-10-20T07:00:00Z. Do not use the wall-clock time of the shop with the label Z. The slots in the period response have the exact values to send.

check-availability is the authority when the customer orders. The create-order request checks the availability again. A slot that was free a minute ago can get the response 409 PRODUCT_NOT_AVAILABLE.

4. Show the price

const total = await api('/calculate-total', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    partnerId: shop.id,
    dateStart: '2026-10-20',
    dateEnd: '2026-10-22',
    orderlines: [{ productId: product.id, quantity: 1 }],
    shippingOptionId: shop.shipping_options[0]?.id,
  }),
});
// total.total, total.shippingPrice, total.deposit, total.orderlines[].pricedUnits

total is the subtotal plus shipping. The deposit is separate and is not part of total. deposit.amount is a decimal string. deposit.mode shows how the shop collects the deposit. pricedUnits shows how a line total is made. It has one entry for each day or block, then one entry for each discount tier that applied.

A shipping option must be allowed for each product in the cart. If not, the response is 409 SHIPPING_OPTION_NOT_ALLOWED. A base option is always allowed. Do not show the options in disabled_shipping_option_ids of the product.

5. Create the order

First, get the payment methods that the customer can use:

const methods = await api(`/payment-methods/${shop.id}`);
// [{ id: 'ideal', description: 'iDEAL', image }, ..., { id: 'custom', ... }]

custom is the own offline option of the shop. The customer pays the shop directly and not online. Then create the order with the id of the selected method:

const methodId = 'ideal'; // the id of the payment method that the customer selected
const key = crypto.randomUUID(); // the Idempotency-Key

const { order, checkout_url } = await api('/create-order', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'Idempotency-Key': key },
  body: JSON.stringify({
    partner: shop.id,
    date_start: '2026-10-20',
    date_end: '2026-10-22',
    orderline: [{ product: product.id, quantity: 1 }],
    customer: {
      firstname: 'Jan', lastname: 'de Vries', email: 'jan@example.com', phonenumber: '0612345678',
      addressline: 'Dorpsstraat 1', city: 'Utrecht', zipcode: '3511 AB', country: shop.country, // the ISO code of the shop country
      conditions: true, // the customer accepted the terms and conditions
    },
    mollie_payment_method_id: methodId,
    shipping_option: shop.shipping_options[0]?.id,
  }),
});

6. Pay and return

Send the customer to checkout_url:

location.assign(checkout_url);

On the return page, read the order and show its state:

const orderId = new URLSearchParams(location.search).get('orderId');
const order = await api(`/order/${orderId}`);

We get the confirmation of a payment a short time after the customer returns. While payment_status is open or pending, poll the order. Poll every few seconds, for a maximum of one minute:

payment_status Show
paid, authorized A thank-you message with order.ordernumber
open, pending "Payment in progress". Continue to poll.
cancelled, expired, failed "The payment failed". Offer a new checkout.

An order with custom stays open, because the customer pays the shop. For this order, payment_method is custom. Do not poll. Show that the customer pays the shop. For an online payment that we did not confirm yet, payment_method is null. After the confirmation, it is the id of the payment method, for example ideal.

If the payment is still open or pending after one minute, stop the polling. Show that you received the order and that the payment is not confirmed yet.

The order has no personal data of the customer. The order id is the only key to the order. Do not show it to other people.

Errors, limits and retries

Read your orders and get webhooks

The steps above build a shop front in the browser. This part is different. It is for your own systems: a script, Zapier, or Make. It gives you the orders of your shop, with the personal data of your customers. Run it on a server. Never put a token in a web page.

Make an API token

The Owner of the shop makes a token in the Be-Bookable app (Settings, Integrations, Orders API & webhooks). Make one token for each program. Then you can revoke one token without a stop of the others.

const orders = await fetch('https://api.be-bookable.com/v1/orders?payment_status=paid&limit=50', {
  headers: { Authorization: `Bearer ${process.env.BB_API_TOKEN}` },
}).then((response) => response.json());

Read orders

Receive webhooks

A webhook is a URL on your server. We send a POST request to it when something happens to an order. Add webhooks in the same place as the tokens.

Event When
order.created The customer places the order. The payment is not confirmed yet. Orders that are never paid also give this event.
order.paid We confirm the payment of the order.
order.status_changed The status of the order changes (for example to reserved or completed).

To get only paid orders, subscribe to order.paid. The URL must use https and a public host name. We refuse addresses of private networks, and we do not follow redirects.

The body is JSON:

{
  "id": "1234",
  "event": "order.paid",
  "created_at": "2026-10-20T09:15:02.000Z",
  "data": { "id": "…", "ordernumber": "1006", "payment_status": "paid", "customer": { "…": "…" }, "orderlines": [] }
}

Verify the signature. Each request has these headers: BB-Webhook-Id, BB-Webhook-Event, BB-Webhook-Timestamp (Unix seconds), and BB-Webhook-Signature. The header has one or two entries v1=<hex>, separated by commas. The signature is the HMAC-SHA256 of <timestamp>.<raw body> with the signing secret of the webhook. Use the raw body, before you parse the JSON. Refuse a request with an old timestamp, so a captured request cannot be sent again.

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

function isFromBeBookable(secret, headers, rawBody, toleranceSeconds = 300) {
  const timestamp = headers['bb-webhook-timestamp'];
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;

  const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
  // During a rotation, the header has a signature for the new and for the old secret.
  return String(headers['bb-webhook-signature'])
    .split(',')
    .map((entry) => entry.trim().replace(/^v1=/, ''))
    .some((received) => received.length === expected.length && timingSafeEqual(Buffer.from(received), Buffer.from(expected)));
}

Retries. If your server does not answer with 2xx, we try again. We wait 1 minute, 5 minutes, 30 minutes, 2 hours, and 12 hours. Then the delivery is failed. Each attempt has a new timestamp and a new signature. The BB-Webhook-Id stays the same. If a webhook fails for several days, we switch it off. The delivery log in the app shows each message, the status code of your answer, and a Send again button. We keep the log for 30 days.

Test your receiver. Do not place an order to test. Open the webhook in the app and choose Send test event. Pick the event type first. We send one fake message to your URL at once. It has the signature of your real secret, so you can check your verification code. The app shows the status code and the start of the answer of your server. A test message has "test": true in the body and the header BB-Webhook-Test: true. The order in it is fake: the order number is TEST-0001, and the customer address ends with @example.com. We do not retry a test message, and it is not in the delivery log.

Change the secret. Use New secret in the app. The old secret keeps working for 24 hours. In that time, each message has a signature for both secrets. Update the secret in your system, and no message fails. End now stops the old secret at once. Use it if the old secret is no longer secret.

Shops that are switched off. A shop that is not visible gets no messages, and its tokens stop working. They work again when the shop is visible.

Zapier and Make. Use the trigger "Webhooks by Zapier" (Catch Hook) or the "Custom webhook" module of Make. Copy the URL that they give into a new webhook. Place a test order to give them a sample. These tools do not check the signature.

Going live

  1. Register the production domain and the return URL of your shop.
  2. Change API to https://api.be-bookable.com/v1.
  3. Make one real order through the complete flow. Use a payment method that you can refund.
  4. Read the changelog of the API regularly.