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.