Developer docs

Events

Render a venue's events with renderEventsWidget — cards, detail, registration, tickets — or build your own event pages on the headless client's event calls.

The same SDK file that renders the booking widget also renders a venue's events: published events as cards, one event's detail, a registration form — free registration for a Free event, a Stripe Checkout for a Paid one — and a confirmation with the ticket codes. Two ways in, both on the same Events API:

  • renderEventsWidget — one call, a complete flow inside an element you choose, style-isolated like the booking widget. Use it when the venue's site has no event pages of its own.
  • The headless client — client.events(), client.event(), client.registerForEvent(), client.checkoutEventTickets(), client.ticket(), client.registration() and client.cancelEventRegistration() next to the booking calls of Custom forms. Use it when the site already has an events section and only needs the data and the registration/checkout call.

The two combine: a site can render the list with its own cards and hand a chosen event to the widget, or the other way round.

Events are a Pro capability and online ticket sales for paid events a Premium one. A venue whose plan does not include events answers an empty list, so the widget shows its empty state rather than an error a guest could not act on — Plans and visibility has the exact answer per route, and the pricing page has what each tier costs.

Install

Load the SDK as for the booking widget — the major-line alias, or an exact version with its integrity hash (CDN) — and add an element for the events:

<div id="bookdineplay-events"></div>
<script src="https://cdn.bookdineplay.com/sdk/v0/bookdineplay.js"></script>

Render the events widget

<script>
  window.BookDinePlay.renderEventsWidget({
    container: '#bookdineplay-events',
    venueSlug: 'your-venue',
    apiBaseUrl: 'https://api.bookdineplay.com',
    publishableKey: 'bdp_pk_your_publishable_key',
    layout: 'grid',
    language: 'en',
    theme: 'auto'
  });
</script>

The call returns the widget instance, or null when it refused to render (When it refuses to render). A page may hold an events widget and a booking widget side by side; they share the script and the key.

Options

Option Required Meaning
container yes A CSS selector or a DOM element. The widget renders inside it.
venueSlug yes Your venue's slug.
apiBaseUrl yes https://api.bookdineplay.com. Only different for a private deployment.
publishableKey yes The venue's bdp_pk_… key. Never a secret key.
layout no grid (default) — cards in columns — or list, one card per row.
eventSlug no Render this one event's detail directly, with no list and no back button — for a page that is the event's page (One event per page).
language no en (default) or de — the widget's own texts (buttons, states, dates) and the Accept-Language it sends, so problem messages come back in the same language. Any de-* tag counts as de. Event titles and descriptions always come in the language the venue wrote them in.
onNavigate no function (slug, summary). When set, choosing a card calls it instead of opening the in-widget detail, so the host routes to its own event page (One event per page).
successUrl no Where a Paid event's Stripe Checkout returns the guest after they pay (Paid events). Omitted, the built-in registration page is used.
cancelUrl no …after they abandon Checkout. Same default.
theme no auto (follow the visitor's system setting, default), light or dark.

What the guest sees

  1. The list — every upcoming published event as a card: picture (with its alt text), a date badge (or "Ongoing" for an event without a date), a chip — Free or Tickets — the title, the excerpt and one line with weekday, date, times and the venue's time label. "Sold out" appears on the detail only, once its registration window is known. A venue with no upcoming events shows an empty state; a venue whose plan does not include events looks the same, because the API answers an empty list.
  2. The detail — picture, title, excerpt, the description as the venue wrote it, and the facts: when, where, price, tickets left. Below that either a Register button or the reason there is none (the state table below). A Paid event with an open window gets the same button as a Free one — only the form behind it differs.
  3. The form — name and e-mail (required), phone, the number of tickets (1 up to the smaller of maxPerRegistration and remaining), one attendee name per ticket (the first mirrors the buyer until edited), notes. Submit stays disabled until the form is valid; an off-screen honeypot field drops bot submissions silently. For a Free event it posts a registration and ends in the in-widget confirmation below; for a Paid event (see Paid events) it shows a live total, submits as a Stripe Checkout instead, and sends the guest to Stripe rather than showing a confirmation. A refused attempt shows the API's reason above the form: the window's own sentence on registration-closed, a generic "not available right now" sentence on ticket-sales-not-enabled, the validation messages on a 400.
  4. The confirmation — Free events only: the reference, every ticket with its code, attendee and a View ticket link to the ticket page, and a Cancel registration link. The same arrives by e-mail once tickets are issued (What the guest receives).

The widget root announces view changes to screen readers (aria-live="polite"); load failures are role="alert" with a retry, loading states role="status".

The detail's state is derived from the event's status and registration — BookDinePlay.helpers.registrationState(detail) returns the same value for a page with its own markup:

State Shown when Text (English)
open A free event whose window is open The Register button
paid A paid event whose window is open The Register button — same as open; the form is a checkout instead
none Registration mode None "No registration needed - just come by."
closed registration.reason is closed "Registration for this event has closed."
sold-out registration.reason is sold-out "This event is sold out."
past registration.reason is past "This event has already taken place."
not-open registration.reason is not-open "Registration is not open for this event."
cancelled status is Cancelled "This event has been cancelled."

A Premium venue with a connected Stripe payout account sells tickets for its Paid events directly through the widget. The detail and the state table above treat a Paid event exactly like a Free one — the same Register button, gated only by the registration window (open/closed/sold-out/past) — because whether the venue can actually sell right now is a separate, narrower question the checkout call answers, not the detail read.

The form for a Paid event adds a live total (price × quantity, formatted for the widget's language with Intl.NumberFormat) above the submit button, and the button itself reads "Pay & register" ("Bezahlen & anmelden" in German) instead of "Confirm registration". Submitting it calls client.checkoutEventTickets(eventSlug, input) instead of registerForEvent and, on success, redirects the guest to Stripe Checkout with window.location.assign(response.checkoutUrl) — there is no in-widget confirmation to show, because tickets are minted once Stripe confirms the payment, which may be seconds after the guest leaves this page.

Below the total, "Have a voucher?" opens a field for a gift voucher code, with "Remove voucher" to take it back. The code is checked in the form first, so a typo is caught without a request; it is then sent as voucherCode and the voucher pays what it covers, the card the rest. A voucher that covers everything needs no payment page: the response's checkoutUrl is then the success page, and the widget follows it all the same. A refused voucher shows its own sentence above the form (Vouchers).

Two widget/client options carry the guest back afterwards:

<script>
  window.BookDinePlay.renderEventsWidget({
    container: '#bookdineplay-events',
    venueSlug: 'your-venue',
    apiBaseUrl: 'https://api.bookdineplay.com',
    publishableKey: 'bdp_pk_your_publishable_key',
    successUrl: 'https://your-site.example/thanks',
    cancelUrl: 'https://your-site.example/tickets'
  });
</script>

Both are optional — omitted, the guest lands on BookDinePlay's own built-in registration page (https://app.bookdineplay.com/r/{cancelToken}), which polls payment status and shows the tickets once issued. When set, each must be an https URL on an origin the calling publishableKey is allowed to use (see Authentication); a URL that fails that check answers 400 (errors.successUrl/errors.cancelUrl) and the widget shows a generic error — check the origin before publishing.

If the venue's plan does not include ticket sales, or it has not connected a payout account yet, checkoutEventTickets is refused with 409 ticket-sales-not-enabled or 409 payouts-not-connected — the widget shows the exact same sentence for both (Errors), because a guest has no use for knowing which one it was.

One event per page

A site that gives each event its own URL — /events/darts-league-night — uses the two options together. On the events overview, route instead of opening the in-widget detail:

<script>
  window.BookDinePlay.renderEventsWidget({
    container: '#bookdineplay-events',
    venueSlug: 'your-venue',
    apiBaseUrl: 'https://api.bookdineplay.com',
    publishableKey: 'bdp_pk_your_publishable_key',
    onNavigate: function (slug) { window.location.href = '/events/' + slug + '/'; }
  });
</script>

On the event page, render that event directly. The widget shows the detail, the form and the confirmation, but no list and no back button — the page's own navigation does that:

<script>
  window.BookDinePlay.renderEventsWidget({
    container: '#bookdineplay-event',
    venueSlug: 'your-venue',
    apiBaseUrl: 'https://api.bookdineplay.com',
    publishableKey: 'bdp_pk_your_publishable_key',
    eventSlug: 'darts-league-night'
  });
</script>

onNavigate receives the card's summary as a second argument — the list item from GET /api/venues/{venueSlug}/events — for a router that wants more than the slug. An eventSlug the API does not know renders "This event is no longer available" with a retry.

Theming

The events widget renders in the same isolated root as the booking widget and reads the same nine --bdp-* custom properties from the host page — --bdp-accent for the chips, badges and the Register button, --bdp-surface for the cards, --bdp-radius for their corners. Set them once and both widgets follow; Theming lists every property with its default. There is no events-specific property.

When it refuses to render

renderEventsWidget returns null and writes one line to the browser console, never throwing into your page — the same three refusals as renderBookingWidget (When it refuses to render): the container was not found, venueSlug or apiBaseUrl is missing, or publishableKey is missing or not a bdp_pk_… key.

The headless client

BookDinePlay.createClient (Custom forms) has seven event calls next to the four booking calls. Every one returns a promise of the parsed JSON body, takes { signal } as its optional last argument, and rejects with a BookDinePlayError (Errors):

Method Endpoint Returns
client.events({ upcoming }) GET /api/venues/{venueSlug}/events The events array of summaries (not the envelope); upcoming defaults to true and is sent only as ?upcoming=false
client.event(eventSlug) GET /api/venues/{venueSlug}/events/{eventSlug} The detail with its registration window; rejects with status: 404 for an unknown or draft event
client.registerForEvent(eventSlug, input) POST …/events/{eventSlug}/registrations The registration — reference, tickets[], cancelUrl
client.checkoutEventTickets(eventSlug, input) POST …/events/{eventSlug}/tickets/checkout The Checkout session — reference, checkoutUrl, expiresAt, cancelToken (Paid events)
client.ticket(code) GET /api/tickets/{code} The ticket with its qrSvg; no key is sent
client.registration(cancelToken) GET /api/registrations/{cancelToken} The guest's own registration — status, payment, tickets[], refundPolicy, canCancel, cancelNote; no key is sent
client.cancelEventRegistration(cancelToken) POST /api/event-registrations/{cancelToken}/cancel The cancelled registration; no key is sent

registerForEvent(eventSlug, input) and checkoutEventTickets(eventSlug, input) take the same fields — buyerName, email, phone, notes, quantity and attendees — sent shaped as the widget does: strings trimmed, phone and notes null when blank, quantity a number, attendees one trimmed name per entry. checkoutEventTickets also takes an optional voucherCode, sent only when present, and additionally sends successUrl/cancelUrl — either per call, as input.successUrl/input.cancelUrl, or the client's own defaults from createClient({ successUrl, cancelUrl }) below (a per-call value wins); neither given sends neither, and the API falls back to its built-in registration page. A missing eventSlug, code or cancelToken throws a TypeError synchronously.

The three keyless calls need no venueSlug: a page that only shows a ticket, a registration or a cancel link may create its client with apiBaseUrl and publishableKey alone, and only the venue-scoped calls throw without a slug.

const client = window.BookDinePlay.createClient({
  apiBaseUrl: 'https://api.bookdineplay.com',
  publishableKey: 'bdp_pk_your_publishable_key',
  venueSlug: 'your-venue',
  language: 'en',
  successUrl: 'https://your-site.example/thanks',   // Paid events only — optional
  cancelUrl: 'https://your-site.example/tickets'     // optional
});

Build your own markup

The whole flow in the host page's own HTML — the sequence the widget runs, with your templates. First the list: one card per event, linking to the event's page. BookDinePlay.helpers has the widget's pure formatting functions (formatEventDate, dateBadge, eventChip, registrationState, maxQuantity) so the dates and states read the same as in the widget:

const helpers = window.BookDinePlay.helpers;
const list = document.querySelector('#events');

async function renderList() {
  const events = await client.events();
  if (!events.length) {
    list.textContent = 'No upcoming events.';
    return;
  }
  for (const event of events) {
    const card = document.createElement('a');
    card.href = '/events/' + event.slug + '/';

    const title = document.createElement('strong');
    title.textContent = event.title;

    const when = document.createElement('span');
    when.textContent = helpers.formatEventDate('en', event.date, event.start, event.end, event.timeLabel);

    const chip = document.createElement('span');
    chip.textContent = event.registrationMode === 'Free' ? 'Free' : event.registrationMode === 'Paid' ? 'Tickets' : '';

    const excerpt = document.createElement('p');
    excerpt.textContent = event.excerpt || '';

    card.append(title, when, chip, excerpt);
    list.append(card);
  }
}
renderList();

Then the event page: read the detail, show the description (descriptionHtml is sanitized on the server), and decide from registrationState whether the form appears:

const slug = location.pathname.split('/').filter(Boolean).pop();
const page = document.querySelector('#event');
const form = document.querySelector('#register');

async function renderEvent() {
  let detail;
  try {
    detail = await client.event(slug);
  } catch (err) {
    page.textContent = err.status === 404 ? 'This event is no longer available.' : err.message;
    throw err;
  }

  page.querySelector('h1').textContent = detail.title;
  page.querySelector('.description').innerHTML = detail.descriptionHtml;
  page.querySelector('.when').textContent =
    helpers.formatEventDate('en', detail.date, detail.start, detail.end, detail.timeLabel);
  page.querySelector('.where').textContent = detail.locationLabel;

  const state = helpers.registrationState(detail);   // 'open' | 'paid' | 'none' | 'closed' | 'sold-out' | 'past' | 'not-open' | 'cancelled'
  if (state === 'open' || state === 'paid') {         // 'paid' is registrable too — see Paid events
    form.hidden = false;
    form.elements.quantity.max = helpers.maxQuantity(detail.registration);
  } else {
    page.querySelector('.state').textContent = stateText(state); // your own wording per state
  }
}
renderEvent();

And the registration: one attendee input per ticket, the first defaulting to the buyer, then the call — checkoutEventTickets for a Paid event, registerForEvent otherwise. A Free event ends in the in-widget confirmation; a Paid one redirects to Stripe and has none to show:

form.addEventListener('submit', async (event) => {
  event.preventDefault();
  const data = new FormData(form);
  const quantity = Number(data.get('quantity'));
  const attendees = [];
  for (let i = 0; i < quantity; i++) attendees.push(data.get('attendee' + i) || '');
  const input = {
    buyerName: data.get('name'), email: data.get('email'), phone: data.get('phone'),
    notes: data.get('notes'), quantity, attendees
  };

  try {
    if (helpers.registrationState(detail) === 'paid') {
      const checkout = await client.checkoutEventTickets(slug, input);
      window.location.assign(checkout.checkoutUrl);   // guest leaves for Stripe — nothing more to render
      return;
    }
    const registration = await client.registerForEvent(slug, input);
    showConfirmation(registration.reference, registration.tickets, registration.cancelUrl);
  } catch (err) {
    if (err.name === 'AbortError') return;
    if (err.reason) {                 // 409 registration-closed: 'closed' | 'sold-out' | 'past' | 'not-open'
      showError(stateText(err.reason));
      if (err.reason === 'sold-out') form.hidden = true;
      return;
    }
    if (err.status === 400) {         // validation — field name (or 'registration') → messages
      showError(err.messages().join(' '));
      return;
    }
    // ticket-sales-not-enabled, payouts-not-connected, 403, network — the API's sentence (both
    // ticket-sales problem types read the same, see Errors below)
    showError(err.message);
  }
});

showConfirmation, showError and stateText are yours. Each ticket has a ticketUrl to link to and a code to print; cancelUrl is the guest's cancel page. The guest gets the same by e-mail once tickets are issued, so a site may show a short confirmation and leave the rest to the inbox.

Ticket, registration and cancel pages on your site

All three pages exist on app.bookdineplay.com and the e-mails link there (What the guest receives). A site that wants them under its own domain renders them from the three keyless calls — a client with no venueSlug is enough here:

const client = window.BookDinePlay.createClient({
  apiBaseUrl: 'https://api.bookdineplay.com',
  publishableKey: 'bdp_pk_your_publishable_key'
});

const code = location.pathname.split('/').pop();
const ticket = await client.ticket(code);             // sends no key; rejects with 404 when unknown
document.querySelector('#qr').innerHTML = ticket.qrSvg;
document.querySelector('#attendee').textContent = ticket.attendeeName;
document.querySelector('#status').textContent = ticket.status;  // 'Valid' | 'CheckedIn' | 'Cancelled'

The cancel page is the same shape, and it starts by showing the guest what they booked: client.registration(cancelToken) answers the whole summary, canCancel says whether the button belongs on the page at all, and cancelNote is the ready-made sentence for why not. Cancelling blind, with no confirmation screen, is never necessary:

const token = new URLSearchParams(location.search).get('token');
const registration = await client.registration(token);   // sends no key; rejects with 404 when unknown
document.querySelector('#event').textContent = registration.eventTitle;
document.querySelector('#status').textContent = registration.status;   // 'Confirmed' | 'PendingPayment' | 'Cancelled'
document.querySelector('#tickets').textContent = registration.quantity;

const button = document.querySelector('#cancel');
button.hidden = !registration.canCancel;
document.querySelector('#why-not').textContent = registration.cancelNote || '';

button.addEventListener('click', async () => {
  const result = await client.cancelEventRegistration(token); // sends no key; 409 once a ticket was checked in
  document.querySelector('#status').textContent = result.status; // 'Cancelled'
});

For a Paid event this same page is where the guest waits while the charge settles — but how it gets the token depends on which return URL you chose (Paid events):

  • Omit successUrl — the guest lands on BookDinePlay's own /r/{cancelToken} page, which already carries the token in its path and needs nothing from you.
  • Supply your own successUrl — the platform passes it through unchanged and appends no token to it. Persist cancelToken from the checkoutEventTickets response yourself before you redirect: sessionStorage, your own backend, or a parameter you put on your own successUrl. A session_id is no substitute — no public route resolves one to a registration.

Either way, poll client.registration(cancelToken) every few seconds until payment leaves 'Pending'. Treat that token as the credential it is: it can both read and cancel the booking, so if you do carry it in a URL, it reaches browser history, Referer headers and every access log on the way — and it does not belong in your own logs.

ticketUrl and cancelUrl in the registration response keep pointing at the BookDinePlay pages; to send guests to yours, build the links from ticket.code and the last segment of cancelUrl.

Errors

Every client call rejects with a BookDinePlayError when the request did not succeed; an AbortError you caused passes through unchanged. On top of the fields from Custom forms — name, status, problem, message — the event problems are lifted onto the error so you never have to know the RFC 7807 shape:

Field Value
type The problem's type URL — ends in registration-closed, ticket-sales-not-enabled, payouts-not-connected, refund-not-available, feature-not-in-plan or a key reason; the generic RFC 9110 URL on a 400/404/plain 409; null when the response had no problem body
reason On registration-closed: 'closed', 'sold-out', 'past' or 'not-open'; otherwise null
remaining 0 on sold-out; otherwise null
errors On a 400: the validation dictionary, field name (or registration for a broken rule) → array of messages; otherwise null
messages() Every message in errors as one flat array — [] when there are none
try {
  await client.checkoutEventTickets(slug, input);   // or registerForEvent for a Free event
} catch (err) {
  if (err.type && (err.type.endsWith('/ticket-sales-not-enabled') || err.type.endsWith('/payouts-not-connected'))) {
    showError('Online ticket sales are not available for this event right now.');
  } else if (err.reason === 'sold-out') showError('Sold out');
  else if (err.reason) showError('Registration is not open');
  else if (err.status === 400) showError(err.messages().join(' '));
  else showError(err.message);
}

ticket-sales-not-enabled and payouts-not-connected are deliberately shown the same sentence — a guest cannot act on either, and telling them apart would leak the venue's plan or billing state to anyone who can reach the API (Errors). The events widget's own registrationFailureText() helper does exactly this mapping.

client.cancelEventRegistration(cancelToken) has one problem type of its own: a Paid registration whose event refund policy no longer allows an online refund answers 409 refund-not-available, whose message is already the right, localized "please contact the venue" sentence — nothing to map, just show it:

try {
  await client.cancelEventRegistration(token);
} catch (err) {
  if (err.type && err.type.endsWith('/refund-not-available')) showError(err.message);
  else showError(err.message);
}

The messages in errors arrive in the client's language. A 403 on registration or checkout is feature-not-in-plan — the venue's plan does not include events — or a key problem; a status of 0 means no answer arrived at all.

Next steps

  • Events API — every field of the list, the detail, the registration, the checkout and the ticket.
  • Errors — registration-closed, ticket-sales-not-enabled, payouts-not-connected and refund-not-available in detail.
  • Custom forms — the booking calls of the same client.
  • WordPress plugin — the same widget as [bookdineplay_events] and [bookdineplay_event].