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
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 for free events, 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.ticket()andclient.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 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.
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). |
theme |
no | auto (follow the visitor's system setting, default), light or dark. |
What the guest sees
- 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.
- 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).
- The form — name and e-mail (required), phone, the number of tickets (1 up to the smaller of
maxPerRegistrationandremaining), 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. A refused registration shows the API's reason above the form: the window's own sentence onregistration-closed, the coming-soon text onticket-sales-not-enabled, the validation messages on a400. - The confirmation — 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 (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 |
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." |
paid |
A paid event whose window is open | "Tickets coming soon" — online ticket sales are not enabled yet |
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 five 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.ticket(code) |
GET /api/tickets/{code} |
The ticket with its qrSvg; no key is sent |
client.cancelEventRegistration(cancelToken) |
POST /api/event-registrations/{cancelToken}/cancel |
The cancelled registration; no key is sent |
registerForEvent(eventSlug, input) takes buyerName, email, phone, notes, quantity and attendees and sends them shaped as the widget does: strings trimmed, phone and notes null when blank, quantity a number, attendees one trimmed name per entry. A missing eventSlug, code or cancelToken throws a TypeError synchronously.
The two keyless calls need no venueSlug: a page that only shows a ticket 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'
});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' | 'none' | 'closed' | 'sold-out' | 'past' | 'not-open' | 'cancelled' | 'paid'
if (state === 'open') {
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. On success show the reference and the tickets; on a refusal show what the error carries:
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) || '');
try {
const registration = await client.registerForEvent(slug, {
buyerName: data.get('name'),
email: data.get('email'),
phone: data.get('phone'),
notes: data.get('notes'),
quantity,
attendees
});
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;
}
showError(err.message); // ticket-sales-not-enabled, 403, network — the API's sentence
}
});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, so a site may show a short confirmation and leave the rest to the inbox.
Ticket and cancel pages on your site
Both 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 two 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'
document.querySelector('#cancel').addEventListener('click', async () => {
const token = new URLSearchParams(location.search).get('token');
const result = await client.cancelEventRegistration(token); // sends no key; 409 once a ticket was checked in
document.querySelector('#status').textContent = result.status; // 'Cancelled'
});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, 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.registerForEvent(slug, input);
} catch (err) {
if (err.type && err.type.endsWith('/ticket-sales-not-enabled')) showError('Tickets coming soon');
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);
}The messages in errors arrive in the client's language. A 403 on registration 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 and the ticket.
- Errors —
registration-closedandticket-sales-not-enabledin detail. - Custom forms — the booking calls of the same client.
- WordPress plugin — the same widget as
[bookdineplay_events]and[bookdineplay_event].