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
- One host. The API is at
https://api.be-bookable.com/v1. - Access for each shop. Switch on the API for a shop in the Be-Bookable app (Settings, Integrations, API access). During the test period, we enable the API on request. In the same place, register the allowed domains and the return URL. An allowed domain is a hostname only, for example
shop.example.com. After the payment, the customer goes to the return URL. - Send requests from the browser. Version 1 (v1) is for requests from the page of your shop, on a registered domain. A server that sends requests for many visitors uses one IP address. All these requests share one set of rate limits. Cache the catalog reads. Send
create-orderfrom the browser of the visitor. The operations for your own systems (see Read your orders and get webhooks) use an API token and a limit for each token. - Do not calculate. We calculate availability, prices, VAT, shipping, deposit, and payment. Your shop only shows the results. Do not add prices yourself.
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, ...
countryis an ISO code, for exampleNL. The shop accepts orders only from this country. Send it ascountryof the customer and of the invoice address. If you send a different country, the response is422 COUNTRY_NOT_ALLOWED.GET /countriesgives a reference list for an address form.timezoneis the IANA timezone of the shop. The opening hours and the hourly times use this timezone.booking_start_modeshows how customers start a booking. They select a product first, a date and time first, or both.
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}`);
- A list has the form
{ "data": [...], "meta": { "truncated": false } }. Categories and products have pages. Ifmeta.truncatedis true, sendmeta.next_cursorascursorin the next request. - Images are ready-made URLs:
thumb(lists and carts),card(product cards),large(product page), andoriginal. Use the URLs as returned. Do not build URLs yourself. - Money is a number in euro. VAT is included.
price_intervalis the billing unit of a product, in minutes. The values 30, 60, and 240 are hourly products. The value 1440 is a day product and 10080 is a week product. Use it to decide if the customer selects days or start times. A product withduration_optionshas fixed durations. A product withis_consumablehas no rental period. This guide does not show these two types.minimum_order_quantityandmaximum_order_quantitylimit the quantity of an order line (0 means no limit).lead_time_minutesis the minimum time between the booking and the start.descriptionof a product andcontentof a legal page are HTML from the shop. Sanitize this HTML before you show it. The example shop has a short function for this.- Variants. A product can have
variants, for example sizes.variant_labelnames the type, for example Size. Each variant has anid, aname, asurchargefor each billing unit, and animage. The array is in the order that the shop wants. For a product with variants, send theidof the selected variant in every request that follows:variantIdin the availability requests and incalculate-total, andvariantincreate-order. Each variant has its own stock. Without a variant, the response is400 VARIANT_REQUIRED. A variant of another product gives400 INVALID_VARIANT. An order line shows the booked variant invariant. GET /opening-hours/{partnerId}gives the opening hours. Day 0 is Sunday. A weekday without an entry is closed.GET /partners/{partnerId}/pages/terms-and-conditions(orprivacy-policy) gives the legal text for the checkout page.
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,
}),
});
- The request does not set the price. We check and calculate everything again.
invoiceis optional. It is an invoice address that is different fromcustomer.customer_noteis also optional.- Send an
Idempotency-Key. Make one UUID for each request. If the request times out or the connection drops, send the same request again with the same key. The response is the original response. No second order is created. Make a new key when the request is different. After a4xxresponse, the customer changes the form. A different request with the old key gets422 IDEMPOTENCY_KEY_REUSED. - The limit is 10 orders each minute for each IP address.
6. Pay and return
Send the customer to checkout_url:
location.assign(checkout_url);
- For an online payment method,
checkout_urlis the payment page. After the payment (or if the customer cancels), the customer goes to the return URL of the shop. We add?orderId=<id>to the URL. - For
custom,checkout_urlis the return 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
- Each error has the form
{ "error": { "code", "message", "details"?, "request_id" } }. Match oncode. Show your own text. The reference has the full list. When you contact us, give therequest_id. It is also in theX-Request-Idheader of each response. 400 VALIDATION_FAILEDhas the fields that are not correct indetails.detailsis a list of{ in, field, message }.fieldis the path in the request, for examplecustomer.email. Do not showmessageto the customer. Show your own text next to the field.409 PRODUCT_NOT_AVAILABLEand422 COUNTRY_NOT_ALLOWEDare normal results of an order. Tell the customer. Do not retry.- The limits are 120 reads and 10 writes each minute for each IP address. The headers
RateLimit-Limit,RateLimit-Remaining, andRateLimit-Resetshow your status. A429response hasRetry-After(in seconds). - Retry after network failures and
5xxresponses. Use the sameIdempotency-Key. Also retry409 IDEMPOTENCY_IN_PROGRESSafter a short wait, and429after the seconds inRetry-After. Do not retry other4xxresponses. The customer or your code must change the request. - In v1, we only add things. New fields, endpoints, and enum values can appear (for example a new error code or order status). Ignore the values that you do not know. See the changelog.
- You see "Failed to fetch" and there is no response? The browser blocked the request. Make sure that the domain of your page is in the allowed domains of the shop. The troubleshooting section of the reference shows how to find the real error.
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.
- The app shows the token once. Copy it at once and keep it secret. A token is as sensitive as an admin login.
- A shop can have 10 tokens. A token reads the orders of that shop only.
- Regenerate gives a token a new secret. Revoke deletes it. The old secret stops working at once.
- Send the token in the
Authorizationheader. Never send it in the URL.
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
GET /orderslists the orders, newest first. The filters arestatus,payment_status,created_from(included), andcreated_to(not included). The list has pages: uselimitandcursoras in the other lists.GET /orders/{orderId}gives one order. An order of another shop gives404 ORDER_NOT_FOUND.- An order has the fields of the order in the shop front. It also has the personal data of the customer:
customer,invoice, andcustomer_note. Deleted orders are not in the list. 401 UNAUTHORIZEDmeans that the token is missing, not valid, or revoked.403 INSUFFICIENT_SCOPEmeans that the token cannot do this request.- Each token can send 600 requests each minute. A token that is not valid counts against your IP address.
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": [] }
}
idis the id of the event. Use it to find a message that you received twice.datais the order asGET /orders/{orderId}shows it at the moment of delivery. It can be newer than the event.- Answer with a
2xxstatus code within 10 seconds. Do the slow work later.
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
- Register the production domain and the return URL of your shop.
- Change
APItohttps://api.be-bookable.com/v1. - Make one real order through the complete flow. Use a payment method that you can refund.
- Read the changelog of the API regularly.