Build a basket and check out
Take a customer from "I want to book this" to a paid, confirmed order. This guide is the long-form walkthrough of the basket lifecycle — creation, items, guests, discounts, reservation, payment, validation, submit, and the things you should subscribe to after.
If you're coming from the availability and booking guide,
you already have a site_id, a chosen offering, and a slot to reserve.
Start at step 2. If you're starting fresh, step 1 covers basket creation
from scratch.
Basket vs Order — what's the difference?
The two are two halves of the same purchase, on either side of the submit boundary — but they also represent two different ways of producing an order:
- A
Basketis the mutable, in-progress cart that the customer (or an integration acting on their behalf) drives through the full booking-engine flow. It exists from the moment you callcreateBasketand lives until the customer either abandons it (it eventually expires) or you callactionSubmitBasket. Items, guests, discounts, payment methods, customer attachments — every change goes through the basket. The basket carries projected totals that update as you add or remove items, but no money has moved yet. Submission enforces the booking-engine rules: availability checks, per-offering reservation policies, practitioner / room / area constraints, deposit requirements, intake-form completion. If anything breaks one of those rules, submit fails and nothing is reserved. This is the path you want for any customer-driven purchase. - An
Orderis the record of a completed purchase. The order endpoints behave like the basket endpoints in shape — items, guests, payments, vouchers, refunds — but they're designed for internal users creating orders directly, bypassing the booking-engine validation thatactionSubmitBasketruns. Use them when an operator needs to backfill a historical sale, record an off-platform booking, fix up a customer's order after the fact, or add / refund a payment against a submitted order. Edits after a basket-submitted order are also tracked here — refunding a payment, recording an additional payment, cancelling an item. The basket id (if any) is preserved on the order asbasket_idso you can trace either way.
Practically: if a customer is checking out, use
Baskets all the way through
actionSubmitBasket so the booking engine validates and reserves
correctly. Use Orders directly when an internal
caller is producing an order outside the booking-engine flow, or
when adjusting an already-submitted order. The same shapes turn up
in both — items, guests, totals, payments — but the order endpoints
trust the caller to enforce the rules themselves rather than running
the booking-engine validation suite.
The two webhooks that mark the handoff:
basket.submitted— fires once the order has been created and any bookings have been confirmed / enquiry-queued. Thedata.order_idis your read-side handle from this point on.basket.settled— fires when every outstanding payment on the order has captured cleanly. Treat this as the "money in the bank, you can fulfil" signal rather thanbasket.submitted.
Before you start
- A personal access token for the playground
environment with the
reservations.managepermission on the target site. The same flow works in production with a production token; build against playground first. - A
site_idto scope the basket to. Baskets are always bound to one site — they cannot span a brand. - A customer record (or the details to create one). The basket can be created anonymously, but a customer must be attached before items can be reserved or the basket submitted.
- At least one offering you intend to add. See the availability guide for how to resolve an offering and a slot.
- A webhook configuration listening for
basket.submittedandbasket.settledif you intend to react to checkout completion asynchronously. The webhooks guide explains the setup.
How a basket moves through the API
The basket is the single source of truth for everything that becomes an order. Its lifecycle, end-to-end, runs through five states:
- In progress — the basket has been created and is accepting items, guests, discounts, and a customer. This is where you spend most of the API surface.
- Reserved — items have been soft-held for the customer for a short window. Reserved baskets still accept changes, but a clock is now ticking and resources won't be released to other bookings until either submit completes or the reservation window expires.
- Pre-submitted — all validation has passed. A pre-submit is a
dress rehearsal for submit; it doesn't change state on its own but
leaves
submit_errorspopulated when something will fail. - Submitted — the customer has paid (or the payment requirement was
waived), the order has been created, calendars have been written, and
confirmation messages have gone out. The basket transitions to
submittedand emitsbasket.submitted. - Settled — payments have actually cleared. For card payments this
may be the same instant as submit; for delayed-capture or vouchers it
may follow. Settlement emits
basket.settled.
Each of the steps below maps to one operation and one HTTP call. Wire your UI as a state machine over these states, not as a long-running session that holds the basket in memory.
1. Create the basket
Create a basket against the site. Optionally pre-link a customer; in this guide we'll attach the customer in a later step to show the explicit flow.
curl https://api.playground.try.be/shop/basket \
-X POST \
-H "Authorization: Bearer $TRYBE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "site_id": "00000000-0000-0000-0000-111111111111" }'
const res = await fetch('https://api.playground.try.be/shop/basket', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.TRYBE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
site_id: '00000000-0000-0000-0000-111111111111',
}),
})
const { data: basket } = await res.json()
const basketId = basket.id
The response is the new Basket with an id, an empty items array,
and a status of in_progress. Keep the id — every endpoint below
takes it as a path parameter.
See createBasket for the full
request body. If you're calling on behalf of an authenticated customer
(via the SSO flow), the customer is linked automatically and you can
skip step 4.
2. Add items
Items can be added one at a time with createBasketItem. The body shape
varies by offering type, but the required fields are always
offering_type and (for everything other than free-form vouchers)
offering_id. Add date_time, duration, guests, and any
offering-specific configuration as needed.
curl "https://api.playground.try.be/shop/basket/$BASKET_ID/items" \
-X POST \
-H "Authorization: Bearer $TRYBE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"offering_id": "5e932c0901d210625e3a8766",
"offering_type": "appointment",
"date_time": "2026-06-15T14:00:00+01:00",
"guests": [
{ "name": "Jane Doe", "is_lead_booker": true }
]
}'
$response = Http::asJson()
->withToken(config('services.trybe.token'))
->post("https://api.playground.try.be/shop/basket/{$basketId}/items", [
'offering_id' => '5e932c0901d210625e3a8766',
'offering_type' => 'appointment',
'date_time' => '2026-06-15T14:00:00+01:00',
'guests' => [[
'name' => 'Jane Doe',
'is_lead_booker' => true,
]],
]);
$basket = $response->json('data');
The response is the entire updated basket — every operation that mutates
the basket returns the full new state so you don't need a follow-up
getBasket to refresh your UI.
To override the offering's headline price (for a manual discount or a
member rate), include price in the smallest currency unit. To apply a
fixed-amount discount instead, include discount_amount. Either field
is interpreted in the basket's currency, which is taken from the site.
Packages
A package is one parent item plus one nested child item per configured choice. Add the parent first:
curl "https://api.playground.try.be/shop/basket/$BASKET_ID/items" \
-X POST \
-H "Authorization: Bearer $TRYBE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"offering_id": "5e932c0901d210625e471af1",
"offering_type": "package",
"date": "2026-06-15",
"guests": [{ "name": "Jane Doe", "is_lead_booker": true }]
}'
Then, for each choice in the package, add a child via the nested
package-items endpoint. Each choice has its own choice_id, the
selected offering has its own option_id, and any time-sensitive choice
needs an item_configuration carrying time and duration:
curl "https://api.playground.try.be/shop/basket/$BASKET_ID/items/$ITEM_ID/package-items" \
-X POST \
-H "Authorization: Bearer $TRYBE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"choice_id": "5f1111111111111111111111",
"option_id": "5f2222222222222222222222",
"item_configuration": {
"time": "11:00",
"duration": 60
}
}'
See createBasketPackageItem
for the full body shape, including the deprecated top-level time /
duration fields preserved for legacy clients.
Editing and removing items
Items are first-class resources once added — they have IDs, they accept
updates, and they can be deleted. Use
updateBasketItem to change a
slot or guest assignment without removing and re-adding,
updateBasketPackageItem
for nested package children, and
deleteBasketItem when the
customer changes their mind. Every mutation returns the latest basket.
3. Inspect the basket
At any point you can fetch the latest state with getBasket:
curl "https://api.playground.try.be/shop/basket/$BASKET_ID" \
-H "Authorization: Bearer $TRYBE_API_KEY" \
-H "Accept: application/json"
const res = await fetch(
`https://api.playground.try.be/shop/basket/${basketId}`,
{
headers: { Authorization: `Bearer ${process.env.TRYBE_API_KEY}` },
},
)
const { data: basket } = await res.json()
The basket payload includes the items, guests, applied discounts, attached customer, totals, and the outstanding amount the customer must pay. Use it as the source of truth for the cart UI; never compute totals client-side. The server computes price rules, taxes, payment splits, and rounding once, and only the server knows the right answer.
See getBasket.
4. Assign guests and the paying customer
Once the customer has filled in their details, push those onto the basket. There are two related calls:
Bulk-update the guests
A multi-guest booking — e.g. a couples' treatment, a hen party, a corporate booking — usually presents one form per guest. Send the collected details in one shot with the bulk-update endpoint, which is cheaper than mutating one guest at a time and keeps the basket totals consistent:
curl "https://api.playground.try.be/shop/basket/$BASKET_ID/update-guests" \
-X POST \
-H "Authorization: Bearer $TRYBE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"guests": [
{
"id": "5f4444444444444444444444",
"name": "Jane Doe",
"email": "jane@example.com",
"phone": "+447700900000"
},
{
"name": "John Doe",
"email": "john@example.com"
}
]
}'
Entries with an id update an existing guest in place; entries without
one create new guests. Anything you don't include is left untouched.
See actionBulkUpdateBasketGuests.
Set the paying customer
The paying customer is the Customer against whom the resulting Order
is recorded, marketing preferences are saved, and (if a payment method
on file is used) charged. Set them explicitly with actionSetBasketCustomer.
If the request is already authenticated as a customer (via the SSO flow),
the authenticated customer is linked and you only need to send a phone
if one isn't on file:
curl "https://api.playground.try.be/shop/basket/$BASKET_ID/customer" \
-X POST \
-H "Authorization: Bearer $TRYBE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "phone": "+447700900000" }'
For a guest-checkout flow, pass enough detail to either resolve an existing customer for the brand by email or create a new one:
curl "https://api.playground.try.be/shop/basket/$BASKET_ID/customer" \
-X POST \
-H "Authorization: Bearer $TRYBE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "jane@example.com",
"first_name": "Jane",
"last_name": "Doe",
"phone": "+447700900000",
"phone_country": "GB"
}'
Phone numbers are normalised to E.164 server-side using phone_country
(or the site's country code) as the default region. See
actionSetBasketCustomer.
5. Apply discounts and credits
Three different things reduce the amount the customer owes, and each has a dedicated endpoint.
Coupons
Coupons are short codes — WELCOME20, SUMMER25 — issued through the
back office. They can grant a percentage discount, a fixed amount off, an
automatic free item, or a pricing-tier unlock. Apply by code:
curl "https://api.playground.try.be/shop/basket/$BASKET_ID/coupons" \
-X POST \
-H "Authorization: Bearer $TRYBE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "code": "WELCOME20" }'
The response is the updated basket with the coupon attached and the
totals recalculated. If the code is invalid, expired, fully redeemed, or
incompatible with the basket's items, the API returns 400 Bad Request
with a human-readable message — surface that verbatim to the customer.
Remove a coupon with
deleteBasketCoupon. See
createBasketCoupon for the
request shape.
Gift vouchers
Gift vouchers are a different beast: they're payment instruments, not discounts. A voucher carries a remaining balance and is partially or fully applied as payment when the basket is submitted. Attach a voucher with its code:
curl "https://api.playground.try.be/shop/basket/$BASKET_ID/vouchers" \
-X POST \
-H "Authorization: Bearer $TRYBE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "code": "GIFT-9F2K-AT3X" }'
The same endpoint accepts both Trybe-native voucher codes (issued by a previous basket submission) and external voucher codes from a third-party voucher integration (e.g. GiftPro). The server picks the right verification path based on the code's prefix and the site's configured integrations. The vouchers guide covers the selling side; this guide stays focused on redemption.
Remove with
deleteBasketVoucher.
Customer credits
When a customer has store credit on file — issued as refund-to-credit, a goodwill gesture, or a loyalty reward — redeem one credit at a time against the basket:
curl "https://api.playground.try.be/shop/basket/$BASKET_ID/customer-credits" \
-X POST \
-H "Authorization: Bearer $TRYBE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "id": "5f6666666666666666666666" }'
The credit ID comes from the customer's credits list — see
listMyAccountCredits for
the self-service variant, or the back-office credit listings. A credit
can only be redeemed against a basket for the customer it belongs to, so
make sure step 4 has run first.
See
createBasketCustomerCredit.
6. Reserve the slots
Reservation is the moment you tell Trybe "the customer is committed
enough that you should hold these resources for them while we collect
payment". It soft-locks every appointment slot, room, area booking, and
session capacity unit referenced in the basket, returning an
items_reserved_until timestamp that is the deadline by which the
basket must be submitted.
A customer must be attached before reservation succeeds (step 4), so this call always follows the customer step in your flow.
curl "https://api.playground.try.be/shop/basket/$BASKET_ID/reserve" \
-X POST \
-H "Authorization: Bearer $TRYBE_API_KEY"
If one or more items have become unavailable since they were added — a
parallel customer beat you to the slot — the call returns 400 Bad Request with a message identifying the conflict. Re-fetch availability
for the affected item, prompt the customer to pick a different slot,
update the basket, and retry the reserve call.
See actionReserveBasket.
7. Take payment
Payment in Trybe is structured around the processor concept. The
basket may need none (the total resolves to zero after discounts and
credits), one (a card), or several (a voucher plus a card to cover the
shortfall). Each payment record carries a processor and an amount.
Fetch the checkout configuration
Before painting a payment form, fetch the checkout configuration. It tells you which terms the customer must accept and whether external voucher redemption is configured for the site:
curl "https://api.playground.try.be/shop/basket/$BASKET_ID/checkout-config" \
-H "Authorization: Bearer $TRYBE_API_KEY" \
-H "Accept: application/json"
Surface the returned terms on the consent step and conditionally
show the "redeem a voucher" affordance when voucher_payment_enabled
is true.
Add a payment
For card payments through Stripe, the most common pattern is to drop the
customer into Stripe Checkout and let the post-redirect handler write
the payment record automatically. You can still POST a payment with
processor: stripe for legacy compatibility — the API accepts it and
treats it as a no-op.
For voucher payments, post one payment per voucher. The amount
defaults to the outstanding submit amount when omitted; pass an explicit
amount to split-pay across multiple methods:
curl "https://api.playground.try.be/shop/basket/$BASKET_ID/payments" \
-X POST \
-H "Authorization: Bearer $TRYBE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"processor": "voucher",
"voucher_code": "GIFT-9F2K-AT3X",
"amount": 4800
}'
$response = Http::asJson()
->withToken(config('services.trybe.token'))
->post("https://api.playground.try.be/shop/basket/{$basketId}/payments", [
'processor' => 'voucher',
'voucher_code' => 'GIFT-9F2K-AT3X',
'amount' => 4800,
]);
If the voucher was already attached via the voucher endpoint in step 5
you can omit voucher_code here and the API will redeem the attached
voucher. Remove a payment record with
deleteBasketPayment.
See createBasketPayment.
8. Submit
Submit is the irreversible step. It runs the full validation suite,
creates the Order, confirms appointments and area bookings (or queues
them as enquiries if that's how the offering is configured), captures or
authorises payments, and sends confirmation email and SMS.
Pre-submit (recommended)
Before you give the customer the "Pay now" affordance, call pre-submit.
It runs the same validation suite as submit, without actually committing
anything. The response is the latest basket payload with submit_errors
populated if anything will fail. Use that to short-circuit the payment
step and ask the customer to fix the issue first.
curl "https://api.playground.try.be/shop/basket/$BASKET_ID/pre-submit" \
-X POST \
-H "Authorization: Bearer $TRYBE_API_KEY"
A 400 Bad Request here means the basket can't even reach the payment
step — typically an unavailable item or a payment-processor
mis-configuration. Surface the message, re-fetch the basket, and prompt
the customer to make the adjustment.
Submit
After payment succeeds, submit the basket:
curl "https://api.playground.try.be/shop/basket/$BASKET_ID/submit" \
-X POST \
-H "Authorization: Bearer $TRYBE_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'
const res = await fetch(
`https://api.playground.try.be/shop/basket/${basketId}/submit`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.TRYBE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
},
)
if (!res.ok) {
const { message } = await res.json()
throw new Error(`Submit failed: ${message}`)
}
const { data: submitted } = await res.json()
You may include guest-checkout fields (customer_id, email,
first_name, last_name, phone) directly in the submit payload as a
shortcut — the server delegates to the same logic as
actionSetBasketCustomer. Most flows will have set the customer in step
4 and submit with an empty body.
Submit is idempotent inside a one-hour window: if you re-call submit on
an already-submitted basket within that window, you get the existing
basket back with the intake-form URL attached. Beyond the window,
already-submitted baskets respond 404. Re-call submit only as part of
a retry after a network failure — never as a "did this go through?"
probe.
See actionSubmitBasket.
9. Intake forms (optional)
If any item in the basket requires a pre-visit intake form — health declarations, allergies, treatment preferences — fetch the link the customer should follow:
curl "https://api.playground.try.be/shop/basket/$BASKET_ID/intake-form" \
-H "Authorization: Bearer $TRYBE_API_KEY" \
-H "Accept: application/json"
The response contains a signed URL the customer can open without further
authentication. Email or text it to them after submit, or render it
directly on the order confirmation page. See
getIntakeFormLink.
Subscribing to outcomes
The synchronous response from actionSubmitBasket tells you the submit
went through. Everything that happens after — settlement, calendar
writes, downstream integrations — is best observed via webhooks. The
events to subscribe to are:
basket.submitted— emitted when an order is submitted, from any source. Use this to trigger your own "order confirmed" workflows.basket.settled— emitted when an order is fully paid. For card payments this often arrives within seconds of submit; for delayed- capture or voucher payments it may follow much later.basket.cancelled— emitted when a submitted order is cancelled (refund, no-show with refund policy, manual operator action).basket_item.added/basket_item.updated— emitted when staff edit a submitted order in the back office.
See the webhooks guide for the payload shape and how to verify signatures.
Reservation and expiry
The reservation model is finer-grained than it first looks. A few behaviours that trip integrators up:
- Items reserve, baskets don't.
actionReserveBasketmoves each appointment / area / session item into areservedstate with a fixed-window hold (typically 15 minutes — confirm for your tenant). The basket itself isn't on a timer; once items expire, the basket survives but the items revert tounreservedand have to be re-checked for availability before they can be submitted. - Submission consumes the reservation. Once
actionSubmitBasketsucceeds the items become bookings; the reservation timer is no longer relevant. - A new reservation costs nothing. If a guest pauses for
longer than the reservation window, re-running
actionReserveBasketre-holds the same slots if they're still free — no data loss. Surface a "still holding your slots…" UI hint instead of a hard error when the window's about to expire. external_refon the order. Set an external reference (your PMS / CRM ID) on a submitted order viaupdateOrder. This field is settable through the API but doesn't currently render in the Trybe back-office UI — it's there for downstream systems to reconcile against your records, not for back-office staff to read.
Hand off to the hosted checkout (checkout-link)
If you'd rather take a customer through the rest of the flow on Trybe's hosted booking pages (consent capture, terms acceptance, payment) rather than building those screens yourself, generate a signed adoption URL:
curl -X POST "https://api.playground.try.be/shop/orders/$ORDER_ID/checkout-link" \
-H "Authorization: Bearer $TRYBE_API_KEY" \
-H "Accept: application/json"
The response returns a one-time signed URL that surfaces the
hosted checkout for that specific order. Redirect (or open in a
new tab; postMessage back) the customer to it; Trybe handles the
remaining steps and fires the same
basket.submitted /
payment.complete webhooks when
the customer finishes.
The signed URL is short-lived and single-use. Re-call the endpoint to generate a fresh one if the customer abandons and comes back later.
Useful when you want to own the front of the funnel (your branded search + selection) but offload the regulatory-heavy checkout surface (3DS, terms, marketing consent) to Trybe.
Going further
- Search availability and book an appointment — the upstream guide; covers how to resolve the offering and slot that become a basket item.
- Sell and redeem gift vouchers — the voucher-side flow that produces the codes you redeem in step 5.
- Manage memberships — for purchases that need a membership lifecycle (joining fee, recurring direct debit) rather than a one-shot basket.
- Endpoint references:
createBasket,createBasketItem,actionReserveBasket,actionPreSubmitBasket,actionSubmitBasket.
Production checklist
- Treat submit as idempotent only by accident. The one-hour window
exists to make retries safe after a network failure, not to allow
re-submits as a probe. Always treat the first submit response as
authoritative; only re-call submit when you have no response at all,
not when you got a
5xxyou'd like to retry. - Always pre-submit before taking payment. A pre-submit costs you
one round-trip and prevents the worst class of bug — taking money for
a basket that turns out to be unsubmittable. Surface
submit_errorsto the customer, don't paper over them. - Watch the reservation window. After
actionReserveBasket, the response'sitems_reserved_untilis a hard deadline. If the customer takes too long to pay, you'll be re-reserving — and may discover the slot has been taken. Show the timer; consider sending the customer to the payment step immediately after reservation rather than lingering on a "review your basket" page. - Subscribe to
basket.submittedandbasket.settled. Don't rely on the submit response alone to drive downstream systems. Webhooks are retried up to three times — if your endpoint is down briefly you won't lose the event. The submit response is one-shot. - Retry only safe operations on
5xx. Reads (getBasket,getBasketCheckoutConfig) are safe to retry with exponential backoff. Writes are not. Pre-submit, reserve, and submit can leave the basket in a state that requires re-fetching before retrying — never blindly re-POST. - Verify webhook signatures. Every webhook delivery includes a
signatureheader. Compute the SHA-256 HMAC of the raw request body using your configured secret and reject the request if it doesn't match. See the webhooks guide. - Don't compute totals client-side. The basket payload's
totalandoutstanding_amountare the only numbers you should show. Price rules, tax, voucher partial-application, and rounding are all server-side concerns. - Send phone numbers in E.164 or with
phone_country. Numbers like07700 900000need aphone_country(GBfor that example) so the API can normalise them. International numbers in E.164 (+44...) don't need the country and travel unchanged. - Log basket IDs, not basket bodies. Baskets contain personal data
(names, emails, phones). Log the
idand rely on a secure store for the bodies; this keeps GDPR data-subject deletion tractable. - Cancel rather than abandon. If the customer bails out of checkout,
there is no
deleteBasket— the basket will time out on its own. But if they explicitly cancel, freeing reserved resources matters; on high-traffic days that's the difference between an empty calendar slot and one that says "reserved" to the operator for the next ten minutes.