Bookings API
This page covers the booking checkout flow (hold, add-ons, offers, codes, confirmation) and the endpoints for changing a booking afterwards. The JavaScript widget uses these same endpoints, so you only need them if you build your own checkout.
Every request needs the x-tenant-id header. Checkout endpoints are public: a booking in hold status can be read and changed by anyone who has its ID. Once a booking is confirmed, reading, cancelling or rescheduling it (including the cancel and reschedule previews and the reschedule history) needs one of:
- a staff JWT (
Authorization: Bearer ...), - a customer JWT for the customer who owns it (
Authorization: Bearer ...orx-customer-token), - the
guestAccessTokenreturned when the booking was confirmed, sent asx-guest-tokenor?token=. Links in confirmation emails carry this token.
Staff-only endpoints also check permissions. See Authorization.
Checkout flow
-
Create a hold.
POST /api/v1/bookings/holdreserves the slot for 15 minutes and returns the price and a pricing hash. -
Set add-ons (optional).
PUT /api/v1/bookings/{bookingId}/addonsreprices the hold. -
Show checkout offers (optional).
GET /api/v1/checkout-offers?bookingId=...returns up to three discounted add-ons. An accepted offer is sent back through the add-ons endpoint. -
Apply a code (optional).
POST /api/v1/bookings/{bookingId}/apply-promotionorPOST /api/v1/bookings/{bookingId}/apply-voucher. -
Pay or confirm.
- Card (Stripe): create a PaymentIntent, confirm it in the browser, then call
POST /api/v1/payments/{bookingId}/confirm. - Bank (Paytrail): create a payment and redirect the customer.
- Pay on arrival or a zero total: call
POST /api/v1/bookings/{bookingId}/confirm.
See Payments API for the payment calls.
- Card (Stripe): create a PaymentIntent, confirm it in the browser, then call
Every call that changes the price returns the new totals and pricing hash. Keep the latest pricingHash and send it when you confirm.
Endpoints
Checkout (public)
| Method | Path | Purpose |
|---|---|---|
| POST | /api/v1/bookings/hold | Create a hold |
| PUT | /api/v1/bookings/{bookingId}/addons | Replace the hold's add-ons and reprice |
| GET | /api/v1/checkout-offers | List checkout offers for a hold |
| POST | /api/v1/bookings/{bookingId}/apply-promotion | Apply a promotion code |
| POST | /api/v1/bookings/{bookingId}/apply-voucher | Apply a voucher or gift card code |
| DELETE | /api/v1/bookings/{bookingId}/remove-promotion | Remove the applied code |
| POST | /api/v1/bookings/{id}/confirm | Confirm a hold without an online card payment |
| POST | /api/v1/bookings/{id}/resolve-conflict | Resolve resource add-on conflicts found at confirm |
| POST | /api/v1/bookings/{bookingId}/participants | Add participants |
| PUT | /api/v1/bookings/{bookingId}/participants | Replace participants |
| GET | /api/v1/bookings/{bookingId}/participants | List participants |
After confirmation
| Method | Path | Who | Purpose |
|---|---|---|---|
| GET | /api/v1/bookings/{bookingId} | Owner or staff | Get a booking |
| GET | /api/v1/bookings/{bookingId}/cancel-preview | Owner or staff | Show the refund the customer would get |
| POST | /api/v1/bookings/{bookingId}/cancel | Owner or staff | Cancel (also releases a hold) |
| GET | /api/v1/bookings/{bookingId}/reschedule-preview | Owner or staff | Show the reschedule policy and fee |
| POST | /api/v1/bookings/{bookingId}/reschedule/preview | Owner or staff | Check a specific new slot without moving |
| POST | /api/v1/bookings/{bookingId}/reschedule/customer | Owner or staff | Move to a new slot under the policy |
| GET | /api/v1/bookings/{bookingId}/reschedule-history | Owner or staff | Past reschedules |
| POST | /api/v1/bookings/{bookingId}/party-size/preview | Owner or staff | Price a party size change |
| PATCH | /api/v1/bookings/{bookingId}/party-size | Owner or staff | Change the party size |
| GET | /api/v1/bookings/{bookingId}/calendar.ics | Public | Download a calendar file |
Staff actions
These need a staff JWT and the listed permission.
| Method | Path | Permission | Purpose |
|---|---|---|---|
| POST | /api/v1/bookings/{bookingId}/mark-arrived | bookings:checkin and bookings:write | Check the customer in |
| POST | /api/v1/bookings/{bookingId}/undo-arrival | bookings:checkin and bookings:write | Undo a check-in |
| POST | /api/v1/bookings/{bookingId}/mark-no-show | bookings:checkin and bookings:write | Mark as no-show |
| POST | /api/v1/bookings/{bookingId}/mark-paid | bookings:write and payments:record | Record a payment taken outside Sessiq |
| POST | /api/v1/bookings/{bookingId}/complete | bookings:write | Mark as completed |
| POST | /api/v1/bookings/{bookingId}/confirm-hold | bookings:write | Confirm a hold on the customer's behalf |
| POST | /api/v1/bookings/{bookingId}/reschedule/admin | admin:bookings:write | Reschedule with optional policy, fee or capacity overrides |
| GET | /api/v1/bookings/{bookingId}/transitions | bookings:read | Status history and allowed next statuses |
| GET | /api/v1/bookings/{bookingId}/available-actions | bookings:read | Actions staff can take now |
| GET | /api/v1/bookings/{bookingId}/payments | bookings:read | Payment transactions |
| GET | /api/v1/bookings | bookings:read | List bookings (site_id, product_id, status, date_from, date_to, customer_email, q) |
| GET | /api/v1/bookings/holds | bookings:read | Active holds for a site and date |
Create a hold
POST /api/v1/bookings/hold
A hold reserves capacity for 15 minutes. If it is not confirmed in time it becomes expired and the capacity goes back on sale. Bookings that span several slots reserve all of them together, or fail.
curl -X POST https://api.sessiq.com/api/v1/bookings/hold \
-H "Content-Type: application/json" \
-H "x-tenant-id: {tenantId}" \
-d '{
"slotId": "slot_123",
"partySize": 4,
"participantTypes": [
{ "participantTypeId": "pt_adult", "count": 2 },
{ "participantTypeId": "pt_child", "count": 2 }
]
}'
| Field | Required | Description |
|---|---|---|
slotId | Yes | Slot from the availability API |
partySize | Yes | 1 to 50 |
variantId | No | Product variant. Resolved from the slot when omitted |
participantTypes | No | [{ participantTypeId, count }] for per-type pricing |
participantAges | No | Ages, for age-based pricing |
participants | No | Named participants (firstName, lastName, ...) |
addons | No | [{ id, qty }]. Most clients set add-ons later with the add-ons endpoint |
resourceAddons | No | Timed resource add-ons such as a party room: [{ configId, startsAt, endsAt, partySize? }] |
roomSelections | No | The chosen room for each "choose one" block: [{ blockOrder, resourceId }]. Omitted blocks are auto-picked |
durationMins | For variable-duration products | Must match one of the product's duration options |
customerName, customerEmail, customerPhone | No | Contact details. Can also be sent at confirmation |
consent | No | Consent snapshot at booking time |
gaClientId, gaSessionId, attribution | No | Analytics identifiers. See Analytics |
To link the booking to a logged-in customer, send their token in x-customer-token.
Response:
{
"booking_id": "bk_abc123",
"lifecycleStatus": "hold",
"paymentStatus": "pending",
"expires_at": "2026-10-15T09:15:00.000Z",
"pricing": {
"items": [{ "name": "Adult", "qty": 2, "unit": 25, "subtotal": 50 }],
"discounts": [],
"tax": { "total": 8.76, "inclusive": true },
"subtotal": 71.24,
"total": 80,
"currency": "EUR",
"hash": "3f2a..."
}
}
Money values are in euros. If an automatic promotion applies, it already shows in pricing.discounts and pricing.total (see Promotions).
A full slot returns 422. Invalid input returns 400.
Set add-ons
PUT /api/v1/bookings/{bookingId}/addons replaces the booking's add-ons and reprices it. Send an empty array to remove them all.
{
"addons": [
{ "id": "addon_equipment", "qty": 2 },
{ "id": "addon_gloves", "qty": 1, "offerId": "off_12345" }
],
"finalizeGroups": true
}
| Field | Description |
|---|---|
addons[].id | Add-on ID |
addons[].qty | Quantity, at least 1 |
addons[].offerId | The checkout offer this line was accepted from. See below |
finalizeGroups | Send true on the customer's last add-on save. It also enforces add-on group minimums ("choose at least N"). Group maximums are always enforced |
The response has booking.subtotal, booking.taxTotal, booking.grandTotal, booking.pricingHash and the priced booking.addons.
Add-ons can be changed while the booking is hold, confirmed or arrived. If a change raises the total of a paid booking, its payment status becomes part_paid. A booking in another status, an expired hold, or too little add-on stock returns 409.
Checkout offers
Checkout offers are discounted add-ons shown before payment ("Add gloves for €3"). Staff set them up in the admin under add-ons.
GET /api/v1/checkout-offers?bookingId={bookingId}&locale=fi
| Query | Required | Description |
|---|---|---|
bookingId | Yes | The hold to find offers for |
locale | No | For translated titles, for example fi. Defaults to en |
{
"offers": [
{
"offerId": "off_12345",
"addonId": "addon_gloves",
"title": "Add grip gloves",
"description": "Recommended for climbing",
"imageUrl": null,
"imageAlt": null,
"mode": "free_qty",
"unitPrice": 3,
"compareAtUnitPrice": 4,
"currency": "EUR",
"vatRate": 0.255,
"suggestedQuantity": 1,
"maxQuantity": null
}
]
}
The endpoint returns at most three offers, in the order staff set. An empty list means there is nothing to show, so skip the step. It returns 404 if the booking is not hold, confirmed or arrived, or the hold has expired.
| Field | Meaning |
|---|---|
mode | yesno (take it or not), free_qty (any quantity), or per_participant |
unitPrice | The price charged if the customer accepts |
compareAtUnitPrice | The normal add-on price. Only present when it is higher than the offer and staff chose to show it |
suggestedQuantity | The party size for per_participant offers, otherwise 1. Do not pre-select it |
maxQuantity | Upper limit, or null |
Accepting an offer. Add the offer's add-on to the PUT .../addons request with its offerId, together with the customer's other add-ons:
const path = '/api/v1/bookings/{bookingId}/addons'.replace(
'{bookingId}',
bookingId,
);
await fetch(apiBase + path, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'x-tenant-id': tenantId },
body: JSON.stringify({
addons: [
{ id: 'addon_equipment', qty: 2 },
{ id: offer.addonId, qty: 1, offerId: offer.offerId },
],
}),
});
The server checks that the offer is valid for this booking and prices the line at the offer price. Keep sending the same offerId on every later add-on update, or the line goes back to the normal price. Once accepted, the offer price is locked for that booking even if staff later change or turn off the offer. An unknown or ineligible offerId returns 400 with CHECKOUT_OFFER_NOT_ELIGIBLE.
Promotion codes and vouchers
Codes can only be applied while the booking is hold. Otherwise the API returns 409.
Promotion code. POST /api/v1/bookings/{bookingId}/apply-promotion
{ "promotionCode": "SUMMER25" }
{
"booking": {
"id": "bk_abc123",
"status": "hold",
"promotionCode": "SUMMER25",
"discountAmount": 20,
"subtotal": 71.24,
"taxTotal": 8.76,
"grandTotal": 60,
"pricingHash": "3f2a...",
"updatedAt": "2026-10-15T09:03:10.000Z"
},
"discount": {
"code": "SUMMER25",
"type": "percentage",
"value": 20,
"appliedAmount": 20
},
"message": "Promotion code applied successfully"
}
A booking carries one promotion. If an automatic promotion already gives a bigger discount, it stays, and message says so. An invalid, expired or out-of-scope code returns 400 and leaves the booking unchanged.
Voucher or gift card. POST /api/v1/bookings/{bookingId}/apply-voucher
{ "voucherCode": "GIFT-ABCD-1234" }
The response has the same booking totals plus a voucher object with code, appliedAmount, balanceBefore, balanceAfter and isFullyUsed. The voucher covers up to the booking total; any leftover balance stays on the code.
Remove. DELETE /api/v1/bookings/{bookingId}/remove-promotion removes the applied code, returns voucher balance to the code, and brings back any automatic promotion that applies.
Confirm a booking
POST /api/v1/bookings/{id}/confirm confirms a hold that is not paid by card in the browser.
{
"expected_pricing_hash": "3f2a...",
"payment_mode": "poa",
"guest_email": "customer@example.com",
"guest_name": "Maija Meikäläinen",
"guest_phone": "+358401234567"
}
| Field | Required | Description |
|---|---|---|
expected_pricing_hash | Yes | The latest pricingHash you received |
payment_mode | No | poa (pay on arrival) or stripe (default). Any other value returns 400 PAYMENT_MODE_NOT_SUPPORTED |
payment_intent_id | With stripe | A PaymentIntent the customer already paid. Most clients use POST /api/v1/payments/{bookingId}/confirm instead |
guest_email, guest_name, guest_phone | No | Contact details for a guest checkout |
What happens depends on the mode and the total:
- Zero total (fully covered by a discount or voucher): the booking becomes confirmed and paid, whatever mode you send.
poa: confirmed with paymentpending. Only allowed when the product has pay on arrival turned on, has no deposit, and its booking policy does not require payment up front. Otherwise409withPOA_NOT_ALLOWED,DEPOSIT_REQUIREDorPAYMENT_REQUIRED.
A product with a deposit must be paid online, at least the deposit: pay with paymentAmount through the payments API (see Payments API). The former deposit_later mode, which confirmed without any payment, was removed and now returns 400.
Products that need staff approval return 409 with APPROVAL_REQUIRED.
{
"success": true,
"booking": {
"id": "bk_abc123",
"lifecycleStatus": "confirmed",
"paymentStatus": "pending",
"pricing_validated": true,
"confirmed_at": "2026-10-15T09:05:00.000Z"
},
"guestAccessToken": "eyJhbGciOi...",
"message": "Booking confirmed successfully"
}
Store guestAccessToken if you want to let a guest view or cancel the booking later.
If the booking has timed resource add-ons (for example a party room) that are no longer free, the response has success: false and a conflict object. Send the customer's choice to POST /api/v1/bookings/{id}/resolve-conflict.
Confirm errors
| Status | Error code | Meaning |
|---|---|---|
| 400 | PAYMENT_MODE_NOT_SUPPORTED | payment_mode is not stripe or poa (for example the removed deposit_later) |
| 409 | PRICING_CHANGED | The hash does not match the current price. Fetch the booking and show the new price |
| 409 | — | The booking is not hold, or the hold has expired. Start a new hold |
| 409 | POA_NOT_ALLOWED, PAYMENT_REQUIRED, APPROVAL_REQUIRED | The product's policy does not allow this mode |
| 409 | DEPOSIT_REQUIRED | Pay on arrival was requested for a product with a deposit. Pay the deposit online |
Read a booking
GET /api/v1/bookings/{bookingId} returns booking, capacityInfo, isExpired and timeUntilExpiry. A hold can be read with just the tenant header. A confirmed booking needs one of the tokens listed at the top of this page, or it returns 403.
Cancel
Call GET /api/v1/bookings/{bookingId}/cancel-preview first to show the customer the policy and the refund they would get. Then:
POST /api/v1/bookings/{bookingId}/cancel
{ "reason": "Change of plans" }
Cancelling a hold releases it at once. Cancelling a confirmed booking applies the product's cancellation policy and starts any refund due. The response includes booking and, when money is involved, refund_summary.
Staff can cancel a paid booking without a refund by sending "refundOptedOut": true with a refundOptOutReason. The reason is required and goes to the audit log.
Reschedule
GET /api/v1/bookings/{bookingId}/reschedule-previewshows whether the booking can be moved and what it costs.POST /api/v1/bookings/{bookingId}/reschedule/previewwith{ "newSlotId": "slot_456" }checks one target slot:canReschedule,policyViolations,fee,newSlotand anypricingChanges.POST /api/v1/bookings/{bookingId}/reschedule/customerwith{ "newSlotId": "slot_456", "reason": "..." }moves the booking if the policy allows it. The response hassuccess, the updatedbooking,feeChargedandrescheduleEventId.
Staff use POST /api/v1/bookings/{bookingId}/reschedule/admin, which also accepts waiveFee, skipPolicyChecks and forceCapacity.
Change the party size
POST /api/v1/bookings/{bookingId}/party-size/preview with { "partySize": 6 } returns the new price without changing anything. PATCH /api/v1/bookings/{bookingId}/party-size with the same body makes the change.
Staff actions
- Mark arrived accepts optional
arrivalTime,notes,acknowledgeLatenessand, to take payment at check-in,collectPaymentwithpaymentMethod(stripe_card_present,pos_integrationormanual_cash). - Mark no-show accepts optional
reason,notesandnoShowFee. To charge a no-show fee, see No-show fees. - Mark paid accepts
paymentMethod,transactionId,amountPaidandnotes. - Complete accepts
actualEndTime,notesandserviceFeedback.
Booking status
A booking has two independent statuses.
Lifecycle (lifecycleStatus):
| Value | Meaning |
|---|---|
hold | Reserved for 15 minutes, not yet confirmed |
confirmed | Confirmed |
arrived | Checked in |
completed | Finished |
canceled | Cancelled by the customer, staff or the system |
no_show | The customer did not come |
expired | The hold ran out before confirmation |
Payment (paymentStatus):
| Value | Meaning |
|---|---|
pending | Nothing paid yet |
part_paid | Partly paid, for example a deposit |
paid | Fully paid |
failed | The payment failed |
refunded | Refunded |
A pay-on-arrival booking, for example, is confirmed + pending until the customer pays at the venue.
The pricing hash
Every hold carries a hash of its current price. You send the latest hash when you confirm, and the server rejects the confirm with 409 PRICING_CHANGED if the price has moved. This stops a customer being charged a price they did not see. Always use the pricingHash from the most recent response that returned one.