Developer docs

.NET SDK

Install BookDinePlay.Sdk from NuGet, register the typed client with a secret key, and call venues, availability and reservations from your own application.

BookDinePlay.Sdk is a typed client for the public API, for anything that runs on a server: your own booking page, an internal tool, a service that syncs reservations. It returns the same DTOs the API returns and adds nothing you did not ask for — no retries, no caching, no background work.

Install

dotnet add package BookDinePlay.Sdk

Targets .NET 10. The contracts and the IBookDinePlayClient interface live in BookDinePlay.Shared, which comes along as a dependency.

Register the client

The SDK runs on a server, so it uses a secret key (bdp_sk_…), created in the console under Venue → API keys and shown once. Keep it in configuration or a secret store, never in source:

builder.Services.AddBookDinePlayClient(
    new Uri("https://api.bookdineplay.com"),
    builder.Configuration["BookDinePlay:ApiKey"]!);

This registers a typed HttpClient that sends the key as X-BookDinePlay-Key on every request, and IBookDinePlayClient for injection. A publishable key does not work here: the API refuses one without a browser Origin, and a server has none.

Read venue data

Every read returns null for an unknown venue instead of throwing:

var venue = await client.GetVenueAsync("your-venue", cancellationToken);
var hours = await client.GetOpeningHoursAsync("your-venue", cancellationToken);
var menus = await client.GetMenusAsync("your-venue", cancellationToken);
var resources = await client.GetResourcesAsync("your-venue", cancellationToken);

GetBusinessInfoAsync and GetFloorPlanAsync complete the set.

Check availability and book

Availability takes the venue-local date, the party size, the resource type and an optional duration; a reservation posts the slot the guest chose plus their contact details:

var availability = await client.GetAvailabilityAsync(
    "your-venue",
    DateOnly.FromDateTime(DateTime.Today),
    partySize: 4,
    BookableResourceType.RestaurantTable,
    cancellationToken: cancellationToken);

var slot = availability?.Slots.FirstOrDefault(s => s.Available);
if (slot is null)
{
    return; // nothing free that day
}

var reservation = await client.CreateReservationAsync("your-venue", new CreateReservationRequest
{
    ResourceType = BookableResourceType.RestaurantTable,
    ResourceId = slot.ResourceId,
    Date = availability.Date,
    Start = slot.Start,
    PartySize = 4,
    CustomerName = "Jana Berger",
    Email = "jana@example.com",
    Phone = "+49 30 1234567",
}, cancellationToken);

Console.WriteLine($"Booked {reservation.Reference} at {reservation.Start} ({reservation.Status})");

Date is yyyy-MM-dd and Start is HH:mm, both in the venue's local time — exactly what the availability response gave you. Optional fields cover notes, a games count for per-game pricing, bookable extras, a deposit opt-in and a duration.

CreateReservationAsync has a second overload that takes an idempotencyKey before the cancellation token — mint one per booking attempt (Guid.NewGuid().ToString()) and pass the SAME value on a retry after a timeout or a transient failure, so the API returns the original reservation instead of creating a second one. See Retrying safely with Idempotency-Key for the full contract.

var request = new CreateReservationRequest
{
    ResourceType = BookableResourceType.RestaurantTable,
    ResourceId = slot.ResourceId,
    Date = availability.Date,
    Start = slot.Start,
    PartySize = 4,
    CustomerName = "Jana Berger",
    Email = "jana@example.com",
    Phone = "+49 30 1234567",
};

var key = Guid.NewGuid().ToString();

// Retry the SAME request under the SAME key: the second call replays the first
// reservation instead of creating a second one.
var reservation = await client.CreateReservationAsync("your-venue", request, key, CancellationToken.None);

When you register the client with AddBookDinePlayClient and pass no key of your own, the SDK mints one for you: a fresh value per call, attached before any transport-level retry can run, so an automatic retry of one reservation create — by the resilience handler in a .NET Aspire host, or by any retry policy in your own pipeline — reaches the API under a single key instead of looking like several bookings. Two separate calls always get separate keys, so nothing you deliberately book twice is ever collapsed into one. A key you supply yourself always wins, and only the reservation create is touched; no other call gains a header.

Events

A venue on Pro or higher publishes events; guests register and receive one named ticket per attendee (Events API). The same client covers the seven public calls. The two reads return null for an unknown venue or event; a registration or checkout throws on a refusal:

var events = await client.GetEventsAsync("your-venue", cancellationToken: cancellationToken);      // upcoming only
var all = await client.GetEventsAsync("your-venue", upcoming: false, cancellationToken);           // past dated events too
var detail = await client.GetEventAsync("your-venue", "darts-league-night", cancellationToken);    // null when unknown or unpublished

if (detail?.Registration is { Open: true, Mode: EventRegistrationMode.Free })
{
    var registration = await client.RegisterForEventAsync("your-venue", "darts-league-night",
        new EventRegistrationRequest
        {
            BuyerName = "Ada Lovelace",
            Email = "ada@example.com",
            Quantity = 2,
            Attendees = ["", "Grace Hopper"]   // the first defaults to the buyer
        }, cancellationToken);

    Console.WriteLine($"{registration.Reference}: {registration.Tickets.Count} tickets, cancel at {registration.CancelUrl}");
}
else if (detail?.Registration is { Open: true, Mode: EventRegistrationMode.Paid } paid)
{
    var checkout = await client.CheckoutEventTicketsAsync("your-venue", "gala-night",
        new EventTicketCheckoutRequest
        {
            BuyerName = "Ada Lovelace",
            Email = "ada@example.com",
            Quantity = 2,
            Attendees = ["", "Grace Hopper"],
            SuccessUrl = "https://your-site.example/thanks",   // optional; falls back to the built-in registration page
            CancelUrl = "https://your-site.example/tickets"    // optional
        }, cancellationToken);

    // Send the guest to Stripe; tickets are minted once payment completes.
    Console.WriteLine($"Redirect to {checkout.CheckoutUrl} (expires {checkout.ExpiresAt})");
}

EventDetailResponse.Registration is the window from The registration window — Mode, Price, Currency, Capacity, Remaining, MaxPerRegistration, ClosesAt, Open, Reason. A refused registration or checkout throws BookDinePlayApiException with the problem lifted onto it, so you branch on properties rather than parse JSON:

Member Meaning
StatusCode The HTTP status
Type The problem type URL, or null. Compare with the constants BookDinePlayApiException.RegistrationClosedType, BookDinePlayApiException.TicketSalesNotEnabledType, BookDinePlayApiException.PayoutsNotConnectedType, BookDinePlayApiException.RefundNotAvailableType and BookDinePlayApiException.TenantSuspendedType
Reason On registration-closed: "closed", "sold-out", "past" or "not-open"; else null
Remaining 0 when sold out; else null
Errors A 400's validation dictionary — field name (or "registration" for a broken rule) to messages; empty when none
Messages Every message in Errors, flattened
Extensions Every other member the problem carried (for example requiredPlan on a 403) as raw JsonElements
try
{
    var checkout = await client.CheckoutEventTicketsAsync("your-venue", slug, request, cancellationToken);
}
catch (BookDinePlayApiException ex) when (ex.Type == BookDinePlayApiException.RegistrationClosedType)
{
    // ex.Reason: "closed" | "sold-out" | "past" | "not-open"; ex.Remaining is 0 when sold out
}
catch (BookDinePlayApiException ex) when (ex.Type is BookDinePlayApiException.TicketSalesNotEnabledType or BookDinePlayApiException.PayoutsNotConnectedType)
{
    // a Paid event: online ticket sales are not available right now (three causes fold into one — see
    // https://bookdineplay.com/docs/api/errors#ticket-sales-not-enabled). Show the same sentence for both.
}
catch (BookDinePlayApiException ex) when (ex.StatusCode == HttpStatusCode.BadRequest)
{
    // ex.Errors / ex.Messages: what to fix — including errors.successUrl/errors.cancelUrl when a
    // return URL failed the origin-allowlist check
}

Three calls take no API key — the ticket code, the guest's own cancel token, and (for GetRegistrationAsync) that same cancel token are the credential the guest received by e-mail (Why three routes take no key):

var ticket = await client.GetTicketAsync("ABCDEFGHJKLM", cancellationToken);                 // null when unknown
var summary = await client.GetRegistrationAsync(cancelToken, cancellationToken);             // null when unknown; Payment reflects a Paid checkout in progress
var cancelled = await client.CancelEventRegistrationAsync(cancelToken, cancellationToken);   // null when unknown; throws 409 once checked in, or for a Paid registration the refund policy no longer allows

AddBookDinePlayClient registers a KeylessRouteHandler that strips the X-BookDinePlay-Key header — and a Bearer bdp_… Authorization header — from every request the client marks keyless, so your secret key never travels where nothing asked for it. That is these three, the guest's own booking (GetReservationAsync, CancelReservationAsync), the QR table routes (ResolveQrTokenAsync, GetTableSessionAsync, PlaceOrderAsync, CloseTableSessionAsync, StartTabPaymentAsync) — keyless for the same reason: the token the guest scanned off the table is the credential — and the voucher lookup GetVoucherAsync (Gift vouchers). If you build the HttpClient yourself and set the key as a default header, add the handler to your own pipeline (new KeylessRouteHandler { InnerHandler = … }); without it HttpClient merges the default header into those requests too. The routes ignore a key, so nothing breaks — the handler is hygiene, not a requirement.

Gift vouchers

A venue sells gift vouchers online and guests spend them on deposits, tickets and table tabs (Vouchers). The client has three voucher calls and an overload; paying a deposit or tickets with a voucher needs no new method, only VoucherCode on the request you already send:

// Sell: what the venue offers online, then a checkout for one voucher.
var offers = await client.GetVoucherOffersAsync("your-venue", cancellationToken);   // null when the venue is unknown
if (offers is { OnlineSaleAvailable: true })
{
    var checkout = await client.StartVoucherCheckoutAsync("your-venue",
        new StartVoucherCheckoutRequest
        {
            ProductId = offers.Offers[0].ProductId,
            BuyerName = "Ben Buyer",
            BuyerEmail = "ben@example.com",
            RecipientName = "Ada",               // optional, like the recipient e-mail, message and delivery date
            SuccessUrl = "https://your-site.example/thanks"
        },
        idempotencyKey: null, cancellationToken); // null: the client mints one per call

    Console.WriteLine($"{checkout.Reference}: redirect to {checkout.CheckoutUrl}");
}

// Look up: keyless, null for every code that is not a voucher here.
var voucher = await client.GetVoucherAsync(code, cancellationToken);

// Spend: a deposit, tickets or a table tab, with the card paying the rest.
var intent = await client.CreatePaymentIntentAsync("your-venue", new CreatePaymentIntentRequest
{
    ReservationReference = reservation.Reference,
    Amount = reservation.DepositAmount ?? 0m,   // exactly the deposit
    Currency = reservation.Currency,
    VoucherCode = code
}, cancellationToken);
var paidInFull = intent.CardAmount == 0m;       // no card payment; CheckoutUrl is the confirmation page

var tab = await client.StartTabPaymentAsync(qrToken, code, cancellationToken);   // null on 404/400; a 409 refusal throws
  • GetVoucherOffersAsync answers on every plan; OnlineSaleAvailable and UnavailableReason say whether a checkout can start.
  • StartVoucherCheckoutAsync throws BookDinePlayApiException on a refusal. With idempotencyKey left null, a client from AddBookDinePlayClient mints an Idempotency-Key per call, as for reservations, so a transport retry cannot open two checkouts. The voucher's code is not in the response: it reaches the buyer by e-mail once the payment arrives.
  • GetVoucherAsync takes the code in any form a guest types or scans, a pasted guest-page URL included, and sends no API key. It returns null for every code that is not a voucher here, alike; a 429 from the lookup's rate limit throws BookDinePlayApiException.
  • EventTicketCheckoutRequest.VoucherCode and CreatePaymentIntentRequest.VoucherCode pay part or all with a voucher; VoucherAmount and AmountDue/CardAmount on the response say who pays what. A refused voucher throws with one of the voucher problem types in Type.
  • StartTabPaymentAsync(token, voucherCode, cancellationToken) is keyless like the other QR calls. A null or blank code is the two-argument call. PaidInFull means the voucher covered the tab; the method returns null on a 404 (the token does not resolve, or the voucher is not found) or a 400, and throws BookDinePlayApiException on a 409 — a voucher that is used up, expired, not applicable or busy, a card part under the minimum (Extensions["reason"] is below-card-minimum), a tab already paid or with nothing to pay, or a venue that cannot take cards right now.

A voucher code is a bearer credential, like a ticket code: never log it, and keep it out of URLs of your own.

Errors

  • Reads (Get…Async) return null on 404 and throw BookDinePlayApiException (with StatusCode) on any other failure.
  • Writes (CreateReservationAsync, CreatePaymentIntentAsync, RegisterForEventAsync, CheckoutEventTicketsAsync, StartVoucherCheckoutAsync) throw BookDinePlayApiException on a refusal — a slot taken in the meantime, validation errors, a key problem. The message carries the API's problem detail; Type, Errors and the other members under Events are filled for every problem, not only event ones.
  • Key refusals are 401/403 with the reasons listed under Authentication.
try
{
    var reservation = await client.CreateReservationAsync("your-venue", request, cancellationToken);
}
catch (BookDinePlayApiException ex) when (ex.StatusCode == HttpStatusCode.Conflict)
{
    // the slot was taken between availability and booking — re-check and offer another
}

Other endpoints

The client also wraps payment intents (CreatePaymentIntentAsync), the QR/table-session flow for in-venue ordering (ResolveQrTokenAsync, GetTableSessionAsync, PlaceOrderAsync, CloseTableSessionAsync, StartTabPaymentAsync), guest messaging (StartConversationAsync) and the event and voucher calls above. Method names follow the API routes one to one.

Resilience and testing

The client is a thin HttpClient wrapper: add retries or timeouts with your own HttpClient configuration (for example Microsoft.Extensions.Http.Resilience) on the registration AddBookDinePlayClient returns. In tests, inject a fake IBookDinePlayClient — the interface is in BookDinePlay.Shared, so test projects do not need the SDK package at all.

Binary compatibility across versions

BookDinePlay.Shared ships to NuGet, and its published records grow by adding a trailing optional parameter — Active, SlotDurationOverrideMinutes and ResourceGroupId on BookableResourceDto all arrived this way, most recently seatSold (see the Changelog). That keeps source compatibility: code that already calls the constructor, or builds the record with with, keeps compiling against a newer package version unchanged.

It does not keep binary compatibility, and the difference matters the moment you take a dependency's version bump without recompiling your own project against it — a transitive NuGet restore, a container image rebuilt from a lockfile, a plugin loaded into a host that already carries an older BookDinePlay.Shared on its probing path:

// A published record grows the same way every time: a new parameter appended at the end, with a
// default value — e.g. BookableResourceDto's own Active, then SlotDurationOverrideMinutes, then
// ResourceGroupId, then SeatSold, each added this way in a later release.
public sealed record ExampleDto(string Id, string Name, bool Active = true);

// Code compiled against the OLDER package emits a call to the constructor it saw at build time:
var dto = new ExampleDto("lane-1", "Lane 1");   // 2 arguments — Active defaults at compile time

// A newer package adds a trailing, defaulted SeatSold parameter to the same record. A DEFAULT
// PARAMETER VALUE IS A COMPILE-TIME CONSTRUCT — the call above still targets the 2-argument
// constructor. Swap only the assembly underneath (no recompile, no new source), and that overload
// no longer exists: MissingMethodException at runtime, not a build error.

The generated Deconstruct changes arity the same way, so var (id, name, …) = dto or a positional pattern (dto is (var id, var name, …)) written against the old shape stops compiling on your next build — a source-level break in the same release that was binary-breaking at the runtime level for everyone who does not rebuild.

What this means for you: treat a BookDinePlay.Shared/BookDinePlay.Sdk version bump like any other dependency upgrade that needs a real rebuild, not a drop-in binary swap — recompile your project against the new package rather than only replacing the DLL on disk or relying on a container layer that only refreshes the package cache. This is a deliberate, settled convention on our side (new fields keep arriving as trailing optional constructor parameters, not new types or non-positional records) — not something we plan to change, and not a request for us to change it. The Changelog lists exactly which records grew, release by release, so you know when a rebuild is due.

Next steps