Skip to main content

Bookings API

The Bookings API provides endpoints for creating, managing, and querying bookings. This includes creating temporary holds, confirming bookings with payment, cancelling, rescheduling, and applying promotions.

Create Booking Hold

Create a temporary booking hold that reserves a slot for a limited time. The hold expires automatically if not confirmed within the hold period.

POST /api/v1/bookings/hold

Request Body

{
"productId": "p_demo",
"siteId": "s_demo",
"slotId": "slot_123",
"startsAt": "2025-10-15T10:00:00.000Z",
"partySize": 4,
"variantId": "v_demo",
"customerName": "John Doe",
"customerEmail": "john@example.com",
"customerPhone": "+358401234567",
"participantTypes": [
{
"participantTypeId": "pt_adult",
"count": 2
},
{
"participantTypeId": "pt_child",
"count": 2
}
],
"addons": [
{
"addonId": "addon_123",
"quantity": 1
}
],
"promotionCodes": ["SUMMER25"],
"voucherCodes": []
}

Request Fields

FieldTypeRequiredDescription
productIdstringYesProduct identifier
siteIdstringYesSite identifier
slotIdstringYesSlot identifier from availability query
startsAtstringYesBooking start time (ISO 8601)
partySizenumberYesNumber of participants (1-50)
variantIdstringNoVariant identifier (defaults to first variant)
customerNamestringNoCustomer full name
customerEmailstringNoCustomer email address
customerPhonestringNoCustomer phone number (international format)
participantTypesarrayNoParticipant breakdown by type (for per-participant-type pricing)
participantTypes[].participantTypeIdstringYesParticipant type identifier (e.g., "pt_adult", "pt_child")
participantTypes[].countnumberYesNumber of participants of this type
addonsarrayNoArray of add-on selections
addons[].addonIdstringYesAdd-on identifier
addons[].quantitynumberYesQuantity (for per-participant or free_qty modes)
promotionCodesstring[]NoArray of promotion codes to apply
voucherCodesstring[]NoArray of voucher codes to apply

Response

Status: 201 Created

{
"bookingId": "booking_abc123",
"status": "hold",
"holdExpiresAt": "2025-10-15T10:15:00.000Z",
"sagaCorrelationId": "saga_xyz789",
"slotIds": ["slot_123"],
"pricing": {
"subtotal": 80.0,
"taxTotal": 20.0,
"grandTotal": 100.0,
"currency": "EUR",
"pricingHash": "abc123def456...",
"lineItems": [
{
"type": "product",
"name": "Laser Tag Session",
"quantity": 4,
"unitPrice": 20.0,
"totalPrice": 80.0
},
{
"type": "tax",
"name": "VAT (25%)",
"totalPrice": 20.0
}
]
},
"expiresIn": 900
}

Response Fields

FieldTypeDescription
bookingIdstringUnique booking identifier
statusstringBooking status ("hold")
holdExpiresAtstringWhen the hold expires (ISO 8601)
sagaCorrelationIdstringSaga orchestration correlation ID for tracking
slotIdsstring[]Array of slot IDs occupied by this booking (supports multi-slot)
pricingobjectCalculated pricing
pricing.subtotalnumberSubtotal before tax
pricing.taxTotalnumberTax amount
pricing.grandTotalnumberTotal price including tax
pricing.currencystringCurrency code
pricing.pricingHashstringImportant: Use this hash when confirming
pricing.lineItemsarrayDetailed pricing breakdown
expiresInnumberHold expiration time in seconds (default: 900 = 15 minutes)

Error Responses

400 Bad Request - Invalid request

{
"error": "VALIDATION_ERROR",
"message": "Party size must be between 1 and 50",
"code": 400
}

409 Conflict - Slot unavailable

{
"error": "SLOT_UNAVAILABLE",
"message": "The requested slot is no longer available",
"code": 409
}

Confirm Booking

Confirm a booking hold by completing payment. The booking transitions from "hold" to "confirmed" status.

POST /api/v1/bookings/{id}/confirm

Path Parameters

ParameterTypeRequiredDescription
idstringYesBooking identifier from hold response

Request Body

{
"pricingHash": "abc123def456...",
"paymentMethod": "stripe",
"paymentMethodId": "pm_1234567890",
"customerInfo": {
"name": "John Doe",
"email": "john@example.com",
"phone": "+358401234567"
},
"waiverAccepted": true
}

Request Fields

FieldTypeRequiredDescription
pricingHashstringYesPricing hash from hold response (prevents price changes)
paymentMethodstringYesPayment method: "stripe", "paytrail", "voucher", or "poa" (payment on arrival)
paymentMethodIdstringConditionalPayment method ID (required for card payments)
customerInfoobjectNoCustomer information override
waiverAcceptedbooleanConditionalRequired if product requires waiver

Response

Status: 200 OK

{
"bookingId": "booking_abc123",
"status": "confirmed",
"confirmedAt": "2025-10-15T10:05:00.000Z",
"reference": "BK-2025-10-15-001",
"payment": {
"status": "captured",
"amount": 100.0,
"currency": "EUR",
"method": "stripe",
"transactionId": "txn_1234567890"
},
"notifications": {
"emailSent": true,
"smsSent": false
}
}

Error Responses

409 Conflict - Pricing changed

{
"error": "PRICING_CHANGED",
"message": "Booking pricing has changed since hold was created. Please refresh and try again.",
"code": 409,
"details": {
"currentHash": "xyz789...",
"providedHash": "abc123..."
}
}

409 Conflict - Waiver required

{
"error": "WAIVER_REQUIRED",
"message": "This booking requires accepting a waiver",
"code": 409
}

Get Booking Details

Retrieve detailed information about a specific booking.

GET /api/v1/bookings/{id}

Path Parameters

ParameterTypeRequiredDescription
idstringYesBooking identifier

Response

Status: 200 OK

{
"bookingId": "booking_abc123",
"status": "confirmed",
"productId": "p_demo",
"productName": "Laser Tag Session",
"variantId": "v_demo",
"siteId": "s_demo",
"slotId": "slot_123",
"startsAt": "2025-10-15T10:00:00.000Z",
"endsAt": "2025-10-15T11:00:00.000Z",
"partySize": 4,
"customerName": "John Doe",
"customerEmail": "john@example.com",
"customerPhone": "+358401234567",
"reference": "BK-2025-10-15-001",
"pricing": {
"subtotal": 80.0,
"taxTotal": 20.0,
"grandTotal": 100.0,
"currency": "EUR",
"pricingHash": "abc123def456..."
},
"payment": {
"status": "captured",
"amount": 100.0,
"method": "stripe"
},
"createdAt": "2025-10-15T10:00:00.000Z",
"updatedAt": "2025-10-15T10:05:00.000Z"
}

Cancel Booking

Cancel a booking and process refunds according to the cancellation policy.

POST /api/v1/bookings/{id}/cancel

Path Parameters

ParameterTypeRequiredDescription
idstringYesBooking identifier

Request Body

{
"reason": "Change of plans",
"notes": "Customer requested cancellation via phone",
"initiateRefund": true,
"force": false
}

Request Fields

FieldTypeRequiredDescription
reasonstringNoCancellation reason
notesstringNoAdditional notes
initiateRefundbooleanNoWhether to initiate refund (default: true)
forcebooleanNoForce cancellation even if outside policy window (admin only)

Response

Status: 200 OK

{
"booking": {
"id": "booking_abc123",
"previous_status": "confirmed",
"status": "canceled",
"updated_at": "2025-10-15T14:00:00.000Z"
},
"refund_summary": {
"refund_amount": 75.0,
"fee_applied": 25.0,
"net_refund_amount": 50.0,
"refund_status": "processing",
"refund_method": "stripe",
"refund_reference": "ref_1234567890",
"processing_time": "3-5 business days"
},
"message": "Booking cancelled successfully",
"notification_id": "notif_12345"
}

Error Responses

409 Conflict - Outside cancellation window

{
"error": "OUTSIDE_WINDOW",
"message": "Cancellation is outside the allowed window",
"code": 409,
"details": {
"window_hours": 24,
"deadline": "2025-10-14T10:00:00.000Z",
"current_time": "2025-10-15T14:00:00.000Z"
}
}

Reschedule Booking

Reschedule a booking to a new time slot.

POST /api/v1/bookings/{id}/reschedule

Path Parameters

ParameterTypeRequiredDescription
idstringYesBooking identifier

Request Body

{
"newStartsAt": "2025-10-16T15:30:00.000Z",
"newSlotId": "slot_456",
"reason": "Emergency came up",
"partySize": 4,
"force": false
}

Request Fields

FieldTypeRequiredDescription
newStartsAtstringYesNew start time (ISO 8601)
newSlotIdstringNoNew slot ID (if slot-based scheduling)
reasonstringNoReschedule reason
partySizenumberNoNew party size (if different)
forcebooleanNoForce reschedule even if outside policy window (admin only)

Response

Status: 200 OK

{
"booking": {
"id": "booking_abc123",
"previous_starts_at": "2025-10-15T10:00:00.000Z",
"new_starts_at": "2025-10-16T15:30:00.000Z",
"new_ends_at": "2025-10-16T16:30:00.000Z",
"new_slot_id": "slot_456",
"status": "confirmed",
"updated_at": "2025-10-15T14:00:00.000Z",
"pricing_hash": "xyz789..."
},
"payment_adjustment": {
"additional_amount": 10.0,
"fee_amount": 5.0,
"net_amount": 5.0,
"currency": "EUR",
"payment_due_date": "2025-10-16T00:00:00.000Z",
"payment_method": "stripe"
},
"message": "Booking rescheduled successfully",
"notification_id": "notif_12346"
}

Error Responses

409 Conflict - New slot unavailable

{
"error": "SLOT_UNAVAILABLE",
"message": "The requested new slot is not available",
"code": 409,
"details": {
"available_slots": ["slot_789", "slot_790"]
}
}

Apply Promotion Code

Apply a promotion code to an existing booking hold.

POST /api/v1/bookings/{id}/apply-promo

Path Parameters

ParameterTypeRequiredDescription
idstringYesBooking identifier

Request Body

{
"promotion_code": "SUMMER25",
"expected_pricing_hash": "abc123..."
}

Request Fields

FieldTypeRequiredDescription
promotion_codestringYesPromotion code to apply
expected_pricing_hashstringNoCurrent pricing hash (prevents conflicts)

Response

Status: 200 OK

{
"booking": {
"id": "booking_abc123",
"status": "hold",
"promotionCode": "SUMMER25",
"discountAmount": 20.0,
"subtotal": 80.0,
"taxTotal": 20.0,
"grandTotal": 80.0,
"pricingHash": "xyz789...",
"updatedAt": "2025-10-15T10:06:00.000Z"
},
"discount": {
"code": "SUMMER25",
"type": "percentage",
"value": 25,
"appliedAmount": 20.0
},
"message": "Promotion code applied successfully"
}

Error Responses

400 Bad Request - Invalid promotion

{
"error": "INVALID_PROMOTION",
"message": "Promotion code is invalid or not applicable",
"code": 400,
"details": {
"reason": "Minimum spend not met"
}
}

Apply Voucher Code

Apply a voucher (gift card) code to an existing booking hold.

POST /api/v1/bookings/{id}/apply-voucher

Path Parameters

ParameterTypeRequiredDescription
idstringYesBooking identifier

Request Body

{
"voucher_code": "GIFT2024XYZ",
"expected_pricing_hash": "abc123..."
}

Response

Status: 200 OK

{
"booking": {
"id": "booking_abc123",
"status": "hold",
"voucherCode": "GIFT2024XYZ",
"voucherAmount": 50.0,
"subtotal": 80.0,
"taxTotal": 20.0,
"grandTotal": 50.0,
"pricingHash": "xyz789...",
"updatedAt": "2025-10-15T10:07:00.000Z"
},
"voucher": {
"code": "GIFT2024XYZ",
"appliedAmount": 50.0,
"balanceBefore": 100.0,
"balanceAfter": 50.0,
"isFullyUsed": false
},
"message": "Voucher applied successfully"
}

Promotion and Voucher Code Workflow

This section explains how to apply discount codes and vouchers during the booking process.

Overview

Promotion codes (discounts) and voucher codes (gift cards) can be applied to bookings in HOLD status. Once applied, they modify the booking's pricing and generate a new pricing hash that must be used for confirmation.

When to Apply Codes

Codes can be applied:

  • During hold creation: Pass promotionCodes and voucherCodes arrays in the hold request
  • After hold creation: Use the dedicated apply endpoints before confirming the booking

Complete Booking Flow with Codes

// Step 1: Create a booking hold
const holdResponse = await fetch('http://localhost:3001/api/v1/bookings/hold', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'x-tenant-id': 't_demo',
'Content-Type': 'application/json',
},
body: JSON.stringify({
slotId: 'slot_123',
variantId: 'v_standard',
partySize: 4,
customerEmail: 'john@example.com',
customerName: 'John Doe',
}),
});

const hold = await holdResponse.json();
console.log('Initial price:', hold.pricing.grandTotal); // e.g., 100.00
console.log('Initial hash:', hold.pricing.pricingHash); // abc123...

// Step 2 (Optional): Apply promotion code
const promoResponse = await fetch(
`http://localhost:3001/api/v1/bookings/${hold.bookingId}/apply-promo`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'x-tenant-id': 't_demo',
'Content-Type': 'application/json',
},
body: JSON.stringify({
promotion_code: 'SUMMER25',
expected_pricing_hash: hold.pricing.pricingHash,
}),
},
);

const withPromo = await promoResponse.json();
console.log('After promo:', withPromo.booking.grandTotal); // e.g., 75.00 (25% off)
console.log('Discount amount:', withPromo.discount.appliedAmount); // 25.00
console.log('New hash:', withPromo.booking.pricingHash); // xyz789...

// Step 3 (Optional): Apply voucher code
const voucherResponse = await fetch(
`http://localhost:3001/api/v1/bookings/${hold.bookingId}/apply-voucher`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'x-tenant-id': 't_demo',
'Content-Type': 'application/json',
},
body: JSON.stringify({
voucher_code: 'GIFT2024XYZ',
expected_pricing_hash: withPromo.booking.pricingHash,
}),
},
);

const withVoucher = await voucherResponse.json();
console.log('Final price:', withVoucher.booking.grandTotal); // e.g., 25.00 (75 - 50 voucher)
console.log('Voucher applied:', withVoucher.voucher.appliedAmount); // 50.00
console.log('Final hash:', withVoucher.booking.pricingHash); // def456...

// Step 4: Confirm with the LATEST pricing hash
const confirmResponse = await fetch(
`http://localhost:3001/api/v1/bookings/${hold.bookingId}/confirm`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'x-tenant-id': 't_demo',
'Content-Type': 'application/json',
},
body: JSON.stringify({
pricingHash: withVoucher.booking.pricingHash, // Use the LATEST hash!
paymentMethod: 'stripe',
paymentMethodId: 'pm_1234567890',
waiverAccepted: true,
}),
},
);

const confirmed = await confirmResponse.json();
console.log('Booking confirmed:', confirmed.reference);

Key Concepts

Pricing Hash Updates

Each time you apply a code, the booking's pricing is recalculated and a new pricing hash is generated:

  1. Initial hold → Hash A
  2. After promotion code → Hash B (replaces Hash A)
  3. After voucher code → Hash C (replaces Hash B)
  4. Always use the latest hash when confirming

Important: The expected_pricing_hash parameter when applying codes helps prevent race conditions. If the booking's pricing changed since you last saw it, the request will fail with a conflict error.

Code Application Order

  1. Promotion codes first: Percentage or fixed-amount discounts
  2. Voucher codes second: Stored value (gift cards)

This ensures vouchers are applied to the already-discounted price.

Status Requirements

  • Codes can only be applied to bookings in hold or tentative status
  • Once a booking is confirmed or paid, codes cannot be added
  • Expired holds cannot have codes applied

Error Handling

Invalid or Expired Code

{
"error": "INVALID_PROMOTION",
"message": "Promotion code is invalid or expired",
"code": 400,
"details": {
"code": "SUMMER25",
"reason": "expired"
}
}

Minimum Spend Not Met

{
"error": "INVALID_PROMOTION",
"message": "Promotion code is invalid or not applicable",
"code": 400,
"details": {
"code": "VIP50",
"reason": "Minimum spend not met",
"minimumSpend": 200.0,
"currentSpend": 100.0
}
}

Insufficient Voucher Balance

{
"error": "INSUFFICIENT_BALANCE",
"message": "Voucher has insufficient balance",
"code": 400,
"details": {
"voucherCode": "GIFT2024XYZ",
"availableBalance": 25.0,
"attemptedAmount": 50.0
}
}

Pricing Hash Mismatch

{
"error": "PRICING_CHANGED",
"message": "Booking pricing has changed since your last request",
"code": 409,
"details": {
"currentHash": "new789...",
"providedHash": "old123..."
}
}

Solution: Fetch the booking details again to get the current pricing hash and retry.

Best Practices

  1. Store the latest pricing hash: Always update your local state with the new hash after applying codes
  2. Show updated pricing immediately: Display the new totals to users after each code application
  3. Validate codes early: Apply codes during checkout flow, not at confirmation time
  4. Handle errors gracefully: Show clear messages when codes are invalid or not applicable
  5. Race condition prevention: Use expected_pricing_hash when applying codes
  6. Stacking limits: Check if promotion stacking is allowed (some promotions may be exclusive)
  7. Hold expiration: Remember holds expire after 15 minutes - apply codes before expiration

Alternative: Apply Codes During Hold Creation

For simpler flows, you can apply codes when creating the hold:

const hold = await fetch('http://localhost:3001/api/v1/bookings/hold', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'x-tenant-id': 't_demo',
'Content-Type': 'application/json',
},
body: JSON.stringify({
slotId: 'slot_123',
variantId: 'v_standard',
partySize: 4,
customerEmail: 'john@example.com',
promotionCodes: ['SUMMER25'], // Applied immediately
voucherCodes: ['GIFT2024XYZ'], // Applied immediately
}),
});

// Pricing already includes discounts
console.log('Discounted price:', hold.pricing.grandTotal);
console.log('Hash for confirmation:', hold.pricing.pricingHash);

This approach is simpler but less flexible - users cannot add codes after reviewing the initial price.

Mark No-Show

Mark a booking as no-show (admin only).

PATCH /api/v1/bookings/{id}/mark-no-show

Path Parameters

ParameterTypeRequiredDescription
idstringYesBooking identifier

Request Body

{
"markedByUser": "user_123",
"autoDetected": false,
"minutesPastStart": 15,
"reason": "Customer did not arrive",
"notes": "Attempted to contact customer via phone - no response"
}

Response

Status: 200 OK

{
"bookingId": "booking_abc123",
"status": "no_show",
"markedAt": "2025-10-15T10:30:00.000Z",
"noShowFee": {
"amount": 25.0,
"currency": "EUR",
"status": "pending"
}
}

Mark Arrived

Mark a booking as arrived when the customer checks in. Supports optional PoA payment collection at arrival.

POST /api/v1/bookings/{bookingId}/mark-arrived

Path Parameters

ParameterTypeRequiredDescription
bookingIdstringYesBooking identifier

Request Body

{
"collectPayment": true,
"paymentMethod": "manual_cash",
"acknowledgeLateness": false,
"arrivalTime": "2025-10-15T10:05:00.000Z",
"notes": "Customer arrived on time"
}

Request Fields

FieldTypeRequiredDescription
collectPaymentbooleanNoWhether to collect payment at arrival (for PoA bookings)
paymentMethodstringNoPayment method: stripe_card_present, pos_integration, or manual_cash
stripePaymentIntentIdstringNoStripe PaymentIntent ID (if using stripe_card_present)
posTransactionIdstringNoPOS transaction ID (if using pos_integration)
acknowledgeLatenessbooleanNoAcknowledge late arrival if customer is past the start time
arrivalTimestringNoActual arrival time (ISO 8601). Defaults to current time
notesstringNoNotes about the arrival

Response

Status: 200 OK

{
"success": true,
"booking": {
"id": "booking_abc123",
"previous_status": "paid",
"status": "arrived",
"arrival_time": "2025-10-15T10:05:00.000Z",
"confirmed_by": "user_123",
"updated_at": "2025-10-15T10:05:00.000Z"
},
"arrival_info": {
"was_late_arrival": false,
"minutes_late": 0,
"late_arrival_allowed": true
},
"payment_info": {
"payment_collected": true,
"payment_amount": 100.0,
"payment_method": "manual_cash"
},
"message": "Booking marked as arrived"
}

Permissions Required: bookings:write


Mark Paid

Record a payment for a booking, transitioning it to paid status. Used for manual payment recording (e.g., bank transfers, invoices).

POST /api/v1/bookings/{bookingId}/mark-paid

Path Parameters

ParameterTypeRequiredDescription
bookingIdstringYesBooking identifier

Request Body

{
"paymentMethod": "bank_transfer",
"transactionId": "TXN-2025-001",
"amountPaid": 100.0,
"notes": "Payment received via bank transfer"
}

Request Fields

FieldTypeRequiredDescription
paymentMethodstringNoPayment method used
transactionIdstringNoExternal transaction reference
amountPaidnumberNoAmount paid
notesstringNoNotes about the payment

Response

Status: 200 OK

{
"success": true,
"booking": {
"id": "booking_abc123",
"previous_status": "confirmed",
"status": "paid",
"updated_at": "2025-10-15T10:10:00.000Z"
},
"payment_info": {
"payment_method": "bank_transfer",
"transaction_id": "TXN-2025-001",
"amount_paid": 100.0
},
"message": "Booking marked as paid"
}

Permissions Required: bookings:write, payments:record


Complete Booking

Mark a booking as completed after the activity has finished.

POST /api/v1/bookings/{bookingId}/complete

Path Parameters

ParameterTypeRequiredDescription
bookingIdstringYesBooking identifier

Request Body

{
"actualEndTime": "2025-10-15T11:10:00.000Z",
"notes": "Session ran 10 minutes over",
"serviceFeedback": "Great group, very enthusiastic"
}

Request Fields

FieldTypeRequiredDescription
actualEndTimestringNoActual end time if different from scheduled
notesstringNoCompletion notes
serviceFeedbackstringNoStaff feedback about the session

Response

Status: 200 OK

{
"success": true,
"booking": {
"id": "booking_abc123",
"previous_status": "arrived",
"status": "completed",
"completed_at": "2025-10-15T11:10:00.000Z"
},
"completion_info": {
"actual_end_time": "2025-10-15T11:10:00.000Z",
"notes": "Session ran 10 minutes over",
"service_feedback": "Great group, very enthusiastic"
},
"message": "Booking marked as completed"
}

Permissions Required: bookings:write


Modify Resource Addons

Add, remove, or update resource addons on an existing booking. The booking must be in hold, tentative, paid, or confirmed status.

PATCH /api/v1/bookings/{bookingId}/resource-addons

Path Parameters

ParameterTypeRequiredDescription
bookingIdstringYesBooking identifier

Request Body

{
"add": [
{
"resourceAddonId": "ra_extra_equipment",
"quantity": 2
}
],
"remove": ["allocation_id_to_remove"],
"update": [
{
"allocationId": "existing_allocation_id",
"newTimeWindow": {
"startsAt": "2025-10-15T10:00:00.000Z",
"endsAt": "2025-10-15T11:00:00.000Z"
}
}
]
}

Response

Status: 200 OK

{
"bookingId": "booking_abc123",
"addedCount": 1,
"removedCount": 1,
"updatedCount": 1,
"newTotal": 120.0,
"previousTotal": 100.0,
"priceDifference": 20.0,
"resourceAddons": []
}

Get Reschedule History

Retrieve the full reschedule history for a booking, including how many reschedules remain.

GET /api/v1/bookings/{bookingId}/reschedule-history

Path Parameters

ParameterTypeRequiredDescription
bookingIdstringYesBooking identifier

Response

Status: 200 OK

{
"bookingId": "booking_abc123",
"totalReschedules": 2,
"maxReschedules": 3,
"remainingReschedules": 1,
"history": [
{
"id": "re_001",
"originalStartsAt": "2025-10-15T10:00:00.000Z",
"newStartsAt": "2025-10-16T14:00:00.000Z",
"rescheduledAt": "2025-10-14T09:00:00.000Z",
"actorType": "customer",
"reason": "Schedule conflict",
"feeCharged": 0
},
{
"id": "re_002",
"originalStartsAt": "2025-10-16T14:00:00.000Z",
"newStartsAt": "2025-10-17T10:00:00.000Z",
"rescheduledAt": "2025-10-15T08:00:00.000Z",
"actorType": "admin",
"reason": "Resource maintenance",
"feeCharged": 0
}
]
}

Usage Examples

Complete Booking Flow

// 1. Query availability
const slots = await queryAvailability('t_demo', {
productId: 'p_demo',
siteId: 's_demo',
dateFrom: '2025-10-15',
partySize: 4,
});

// 2. Create hold
const hold = await fetch('http://localhost:3001/api/v1/bookings/hold', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'x-tenant-id': 't_demo',
'Content-Type': 'application/json',
},
body: JSON.stringify({
productId: 'p_demo',
siteId: 's_demo',
slotId: slots[0].slotId,
startsAt: slots[0].startsAt,
partySize: 4,
customerName: 'John Doe',
customerEmail: 'john@example.com',
promotionCodes: ['SUMMER25'],
}),
});

const holdData = await hold.json();

// 3. Confirm booking
const confirm = await fetch(
`http://localhost:3001/api/v1/bookings/${holdData.bookingId}/confirm`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'x-tenant-id': 't_demo',
'Content-Type': 'application/json',
},
body: JSON.stringify({
pricingHash: holdData.pricing.pricingHash,
paymentMethod: 'stripe',
paymentMethodId: 'pm_1234567890',
waiverAccepted: true,
}),
},
);

const confirmed = await confirm.json();
console.log('Booking confirmed:', confirmed.reference);

Booking Statuses

Bookings progress through these statuses:

StatusValueDescription
HoldholdTemporary reservation awaiting confirmation (15-minute TTL by default)
TentativetentativePayment-on-Arrival (PoA) booking — customer will pay at the venue
ConfirmedconfirmedBooking confirmed after online payment is initiated
Partially Paidpart_paidPartial payment received (e.g., voucher applied, remaining balance pending)
PaidpaidFully paid
ArrivedarrivedCustomer has checked in at the venue
CompletedcompletedActivity has finished successfully
CancelledcanceledBooking was cancelled by customer, admin, or system
No Showno_showCustomer did not arrive for their booking
ExpiredexpiredHold expired before confirmation (capacity auto-released)

Status Transitions

Main flow:      hold → confirmed → paid → arrived → completed
PoA flow: hold → tentative → paid → arrived → completed
Partial pay: confirmed → part_paid → paid → arrived → completed

Cancellation: hold/tentative/confirmed/part_paid/paid → canceled
No-show: tentative/confirmed/part_paid/paid → no_show
arrived → no_show (late correction)
Expiration: hold → expired
Recovery: no_show → arrived (admin correction)
expired → hold (manual re-hold) or canceled

Hold Expiration: Holds automatically expire after the TTL period (configurable, default 15 minutes). Expired holds release capacity and cannot be confirmed.

Important Notes

Pricing Hash Validation

The pricing hash is a cryptographic hash of the booking's pricing calculation that prevents price manipulation and ensures pricing consistency:

How It Works:

  1. Hold Creation: System calculates price and generates hash
  2. Client Storage: Client stores the pricingHash from hold response
  3. Confirmation: Client sends hash with confirm request
  4. Server Validation: Server recalculates price and compares hashes
  5. Result:
    • Hash match → Booking proceeds
    • Hash mismatch → 409 PRICING_CHANGED error returned

Why It's Important:

  • Prevents race conditions where pricing rules change between hold and confirm
  • Protects against pricing manipulation
  • Ensures customer sees the exact price they agreed to
  • Required for PCI DSS compliance

Best Practice: Always include the pricingHash in your confirm request. If you receive a PRICING_CHANGED error, refresh the pricing by creating a new hold.

Hold Expiration (TTL-Based)

Holds use a Time-To-Live (TTL) mechanism for automatic expiration:

  • Default TTL: 15 minutes (900 seconds)
  • Expiration Field: holdExpiresAt (ISO 8601 timestamp)
  • Automatic Release: When a hold expires:
    • Booking status remains hold but cannot be confirmed
    • Capacity is automatically released back to the pool
    • Multi-slot bookings release all associated slots atomically
  • Extension: Holds are automatically extended to tentative status when payment processing begins

Best Practice: Always confirm bookings before holdExpiresAt. Monitor the expiresIn field (seconds remaining) to warn users of approaching expiration.

Idempotency

Use idempotency keys when creating holds to prevent duplicate bookings if a request is retried:

POST /api/v1/bookings/hold
Idempotency-Key: unique-request-id-12345

Multi-Slot Bookings

The booking system supports bookings that span multiple time slots with atomic capacity management:

How It Works:

  • When creating a hold, the system automatically detects if the booking duration requires multiple slots
  • All required slots are locked atomically (all-or-nothing)
  • The slotIds array in the response contains all occupied slot IDs
  • On cancellation, all slots are released simultaneously

Example Scenario:

Booking Duration: 4 hours
Slot Duration: 2 hours
Result: 2 slots reserved (slot_123, slot_124)

Response slotIds: ["slot_123", "slot_124"]

Benefits:

  • Prevents partial booking failures
  • Ensures capacity consistency across all slots
  • Automatic coordination with availability system
  • Simplified cancellation and rescheduling

Error Handling

Always check for 409 Conflict errors which indicate business rule violations:

  • PRICING_CHANGED: Pricing updated, refresh and retry
  • SLOT_UNAVAILABLE: Slot no longer available
  • OUTSIDE_WINDOW: Request outside policy window