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
| Field | Type | Required | Description |
|---|---|---|---|
productId | string | Yes | Product identifier |
siteId | string | Yes | Site identifier |
slotId | string | Yes | Slot identifier from availability query |
startsAt | string | Yes | Booking start time (ISO 8601) |
partySize | number | Yes | Number of participants (1-50) |
variantId | string | No | Variant identifier (defaults to first variant) |
customerName | string | No | Customer full name |
customerEmail | string | No | Customer email address |
customerPhone | string | No | Customer phone number (international format) |
participantTypes | array | No | Participant breakdown by type (for per-participant-type pricing) |
participantTypes[].participantTypeId | string | Yes | Participant type identifier (e.g., "pt_adult", "pt_child") |
participantTypes[].count | number | Yes | Number of participants of this type |
addons | array | No | Array of add-on selections |
addons[].addonId | string | Yes | Add-on identifier |
addons[].quantity | number | Yes | Quantity (for per-participant or free_qty modes) |
promotionCodes | string[] | No | Array of promotion codes to apply |
voucherCodes | string[] | No | Array 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
| Field | Type | Description |
|---|---|---|
bookingId | string | Unique booking identifier |
status | string | Booking status ("hold") |
holdExpiresAt | string | When the hold expires (ISO 8601) |
sagaCorrelationId | string | Saga orchestration correlation ID for tracking |
slotIds | string[] | Array of slot IDs occupied by this booking (supports multi-slot) |
pricing | object | Calculated pricing |
pricing.subtotal | number | Subtotal before tax |
pricing.taxTotal | number | Tax amount |
pricing.grandTotal | number | Total price including tax |
pricing.currency | string | Currency code |
pricing.pricingHash | string | Important: Use this hash when confirming |
pricing.lineItems | array | Detailed pricing breakdown |
expiresIn | number | Hold 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
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Booking 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
| Field | Type | Required | Description |
|---|---|---|---|
pricingHash | string | Yes | Pricing hash from hold response (prevents price changes) |
paymentMethod | string | Yes | Payment method: "stripe", "paytrail", "voucher", or "poa" (payment on arrival) |
paymentMethodId | string | Conditional | Payment method ID (required for card payments) |
customerInfo | object | No | Customer information override |
waiverAccepted | boolean | Conditional | Required 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
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Booking 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
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Booking identifier |
Request Body
{
"reason": "Change of plans",
"notes": "Customer requested cancellation via phone",
"initiateRefund": true,
"force": false
}
Request Fields
| Field | Type | Required | Description |
|---|---|---|---|
reason | string | No | Cancellation reason |
notes | string | No | Additional notes |
initiateRefund | boolean | No | Whether to initiate refund (default: true) |
force | boolean | No | Force 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
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Booking identifier |
Request Body
{
"newStartsAt": "2025-10-16T15:30:00.000Z",
"newSlotId": "slot_456",
"reason": "Emergency came up",
"partySize": 4,
"force": false
}
Request Fields
| Field | Type | Required | Description |
|---|---|---|---|
newStartsAt | string | Yes | New start time (ISO 8601) |
newSlotId | string | No | New slot ID (if slot-based scheduling) |
reason | string | No | Reschedule reason |
partySize | number | No | New party size (if different) |
force | boolean | No | Force 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
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Booking identifier |
Request Body
{
"promotion_code": "SUMMER25",
"expected_pricing_hash": "abc123..."
}
Request Fields
| Field | Type | Required | Description |
|---|---|---|---|
promotion_code | string | Yes | Promotion code to apply |
expected_pricing_hash | string | No | Current 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
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Booking 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
promotionCodesandvoucherCodesarrays 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:
- Initial hold → Hash A
- After promotion code → Hash B (replaces Hash A)
- After voucher code → Hash C (replaces Hash B)
- 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
- Promotion codes first: Percentage or fixed-amount discounts
- 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
holdortentativestatus - Once a booking is
confirmedorpaid, 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
- Store the latest pricing hash: Always update your local state with the new hash after applying codes
- Show updated pricing immediately: Display the new totals to users after each code application
- Validate codes early: Apply codes during checkout flow, not at confirmation time
- Handle errors gracefully: Show clear messages when codes are invalid or not applicable
- Race condition prevention: Use
expected_pricing_hashwhen applying codes - Stacking limits: Check if promotion stacking is allowed (some promotions may be exclusive)
- 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
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Booking 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
| Parameter | Type | Required | Description |
|---|---|---|---|
bookingId | string | Yes | Booking identifier |
Request Body
{
"collectPayment": true,
"paymentMethod": "manual_cash",
"acknowledgeLateness": false,
"arrivalTime": "2025-10-15T10:05:00.000Z",
"notes": "Customer arrived on time"
}
Request Fields
| Field | Type | Required | Description |
|---|---|---|---|
collectPayment | boolean | No | Whether to collect payment at arrival (for PoA bookings) |
paymentMethod | string | No | Payment method: stripe_card_present, pos_integration, or manual_cash |
stripePaymentIntentId | string | No | Stripe PaymentIntent ID (if using stripe_card_present) |
posTransactionId | string | No | POS transaction ID (if using pos_integration) |
acknowledgeLateness | boolean | No | Acknowledge late arrival if customer is past the start time |
arrivalTime | string | No | Actual arrival time (ISO 8601). Defaults to current time |
notes | string | No | Notes 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
| Parameter | Type | Required | Description |
|---|---|---|---|
bookingId | string | Yes | Booking identifier |
Request Body
{
"paymentMethod": "bank_transfer",
"transactionId": "TXN-2025-001",
"amountPaid": 100.0,
"notes": "Payment received via bank transfer"
}
Request Fields
| Field | Type | Required | Description |
|---|---|---|---|
paymentMethod | string | No | Payment method used |
transactionId | string | No | External transaction reference |
amountPaid | number | No | Amount paid |
notes | string | No | Notes 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
| Parameter | Type | Required | Description |
|---|---|---|---|
bookingId | string | Yes | Booking identifier |
Request Body
{
"actualEndTime": "2025-10-15T11:10:00.000Z",
"notes": "Session ran 10 minutes over",
"serviceFeedback": "Great group, very enthusiastic"
}
Request Fields
| Field | Type | Required | Description |
|---|---|---|---|
actualEndTime | string | No | Actual end time if different from scheduled |
notes | string | No | Completion notes |
serviceFeedback | string | No | Staff 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
| Parameter | Type | Required | Description |
|---|---|---|---|
bookingId | string | Yes | Booking 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
| Parameter | Type | Required | Description |
|---|---|---|---|
bookingId | string | Yes | Booking 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:
| Status | Value | Description |
|---|---|---|
| Hold | hold | Temporary reservation awaiting confirmation (15-minute TTL by default) |
| Tentative | tentative | Payment-on-Arrival (PoA) booking — customer will pay at the venue |
| Confirmed | confirmed | Booking confirmed after online payment is initiated |
| Partially Paid | part_paid | Partial payment received (e.g., voucher applied, remaining balance pending) |
| Paid | paid | Fully paid |
| Arrived | arrived | Customer has checked in at the venue |
| Completed | completed | Activity has finished successfully |
| Cancelled | canceled | Booking was cancelled by customer, admin, or system |
| No Show | no_show | Customer did not arrive for their booking |
| Expired | expired | Hold 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:
- Hold Creation: System calculates price and generates hash
- Client Storage: Client stores the
pricingHashfrom hold response - Confirmation: Client sends hash with confirm request
- Server Validation: Server recalculates price and compares hashes
- Result:
- Hash match → Booking proceeds
- Hash mismatch →
409 PRICING_CHANGEDerror 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
holdbut cannot be confirmed - Capacity is automatically released back to the pool
- Multi-slot bookings release all associated slots atomically
- Booking status remains
- Extension: Holds are automatically extended to
tentativestatus 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
slotIdsarray 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 retrySLOT_UNAVAILABLE: Slot no longer availableOUTSIDE_WINDOW: Request outside policy window