Sign in to see your venue's slug and publishable key in every sample.
Your account has no venue yet, so the samples keep their placeholders. Sign out
Signed in as · . Create a publishable key in the console and reload to see it here. Sign out
Signed in as · . The samples show your venue's publishable key. Sign out
Developer docs
Custom forms
Keep your own reservation form and let the headless client talk to BookDinePlay: venue, availability and reservation calls with a publishable key, no widget UI.
Many venue sites already have a reservation form that matches their design — one for the billiard tables, one for the dart boards, one for the event area. BookDinePlay.createClient puts BookDinePlay behind such a form without rendering anything: it is the HTTP layer the widget itself runs on, exposed as four promise-returning calls. You keep the markup, the validation and the confirmation screen; the client does the requests, the key header, the error mapping and the aborting.
Install
Load the same SDK file the widget uses. Pin an exact version with its integrity hash so the page never changes underneath you (Pinning an exact version):
<script
src="https://cdn.bookdineplay.com/sdk/0.8.0/bookdineplay.js"
integrity="sha384-jWkVHY/3ODRIOP4AZxnLryBhQa58r73TbB8ZBp+1YGqTk++mutYPqmFp53yUIt9p"
crossorigin="anonymous"></script>The script attaches window.BookDinePlay with createClient next to renderBookingWidget. Nothing renders until you call one of them, so the file is safe to include on every page.
Create a client
One client per venue. It needs the API base URL, the venue's publishable key and the venue slug; an optional language is sent as Accept-Language so problem texts come back in that language:
const client = window.BookDinePlay.createClient({
apiBaseUrl: 'https://api.bookdineplay.com',
publishableKey: 'bdp_pk_your_publishable_key',
venueSlug: 'your-venue',
language: 'en'
});createClient throws a TypeError synchronously when apiBaseUrl or venueSlug is missing, or when publishableKey is missing or is not a bdp_pk_… key — a secret key never belongs in a page, and the client refuses to start with one rather than send it.
The four calls
Every call returns a promise of the parsed JSON body and takes an optional last argument { signal } — an AbortSignal — so you can supersede a request the way the widget does. The responses are exactly the API's; the linked reference pages describe every field.
| Method | Endpoint | Returns |
|---|---|---|
client.venue() |
GET /api/venues/{venueSlug} |
The venue profile — name, timezone, resourceTypes, bookingDurations, extras |
client.floorPlan() |
GET /api/venues/{venueSlug}/floor-plan |
The published table map, or null when the venue has none (404) |
client.availability({ date, partySize, resourceType, durationMinutes }) |
GET /api/venues/{venueSlug}/availability |
The day's slots; durationMinutes is omitted from the query when null |
client.reserve(input) |
POST /api/venues/{venueSlug}/reservations |
The reservation — reference, status, deposit fields |
reserve(input) takes the same plain object the widget builds: resourceType, resourceId, date, start, end, partySize, games, durationMinutes, extras, customerName, email, phone, notes, depositOptIn. Every field is always present in the body, shaped as the widget sends it: partySize, games and durationMinutes become numbers or null; extras is the list of { extraId, quantity } or null when empty; customerName, email, phone and notes are trimmed strings — "" when left out; depositOptIn is true or false; everything else you leave out is null (What the widget sends).
A minimal flow
A billiard form with a date, a player count and the contact fields. Submitting it asks for the day's slots, each free slot becomes a button, and choosing one books it — the same sequence the widget runs, in your markup. The form is read again when a slot is chosen, so a guest who corrects their email after searching still sends the corrected value:
const form = document.querySelector('#billiard-form');
const slotList = document.querySelector('#slots');
let pending = null;
form.addEventListener('submit', async (event) => {
event.preventDefault();
const data = new FormData(form);
if (pending) pending.abort(); // a second click supersedes the first lookup
pending = new AbortController();
try {
const availability = await client.availability({
date: data.get('date'),
partySize: data.get('players'),
resourceType: 'BilliardTable'
}, { signal: pending.signal });
slotList.replaceChildren();
for (const slot of availability.slots.filter((s) => s.available)) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = `${slot.start}–${slot.end} · ${slot.resourceName}`;
button.addEventListener('click', () => reserve(slot));
slotList.append(button);
}
if (!slotList.childElementCount) slotList.textContent = 'No free tables on that day.';
} catch (err) {
if (err.name !== 'AbortError') showError(err.message);
}
});
async function reserve(slot) {
const data = new FormData(form); // read now — the guest may have edited fields since searching
try {
const reservation = await client.reserve({
resourceType: 'BilliardTable',
resourceId: slot.resourceId,
date: data.get('date'),
start: slot.start,
end: slot.end,
partySize: data.get('players'),
customerName: data.get('name'),
email: data.get('email'),
phone: data.get('phone')
});
showConfirmation(reservation.reference, reservation.status);
} catch (err) {
showError(err.message);
}
}showError and showConfirmation are yours — that is the point. The reservation comes back Confirmed, or Pending with depositRequired: true when the venue asks for a deposit (Deposits); the API has emailed the guest by then.
Errors
A call rejects with a BookDinePlayError whenever the request did not succeed. An abort you asked for is the exception: the AbortError passes through unchanged, so err.name === 'AbortError' still means "ignore this".
| Field | Value |
|---|---|
name |
'BookDinePlayError' |
status |
The HTTP status of the refusal — a 401/403 from the key check included, the API makes those readable to your origin; 0 only when no answer arrived at all: offline, DNS, a blocked network |
problem |
The RFC 7807 body when the response was JSON — type, title, detail, and errors on a 400 — else null |
message |
problem.detail, else problem.title, else Request failed (<status>) |
The cases worth handling separately:
async function reserveOrExplain(input) {
try {
return await client.reserve(input);
} catch (err) {
if (err.name === 'AbortError') return; // superseded by you — nothing to show
if (err.status === 400 && err.problem?.errors) { // validation: field name → messages
for (const [field, messages] of Object.entries(err.problem.errors)) markInvalid(field, messages[0]);
return;
}
if (err.status === 409) { await reloadSlots(); return; } // the slot was taken in the meantime
showError(err.message);
}
}A 401 or 403 is the key or the origin, not the guest's input: problem.type ends in one of the six reasons on Errors. A refused origin arrives as a readable 403 whose problem.type ends in origin-not-allowed — the API echoes your origin in Access-Control-Allow-Origin on every rejection precisely so your script can read the reason (Origins). A status of 0 therefore never means a refused key or origin; it means the request got no answer: offline, DNS, a blocked network.
Keys and CORS
The client sends the publishable key as X-BookDinePlay-Key on every call, and the API answers only when the page's origin is on that key's allowlist — scheme, host and port, exactly as the browser sends them in Origin (Origins). Add your live domain, your www variant if you serve one, and http://localhost:* for development.
Never put a secret key in a page. createClient refuses a bdp_sk_… key with a TypeError, and the API refuses one that arrives with an Origin header anyway — a secret key seen by a browser is a published secret; revoke it. If your site sends a Content Security Policy, allow connect-src https://api.bookdineplay.com (Content Security Policy).
Next steps
- Booking flow — the four calls in the order the widget makes them, and every field it sends.
- Theming — if you embed the widget after all, match it to your brand.
- CDN — pinning exact versions, SRI, cache lifetimes.