Supports guest users.
Creates a checkout (reservation) for one or more products at a specific merchant.
The checkout expires if not confirmed within its TTL. Supports idempotency via
the Idempotency-Key header.
Events fired
| Event | Trigger | Description |
|---|---|---|
checkouts.CheckoutStarted | sync | Fired when the checkout is successfully created. |
| Time | Status | User Agent | |
|---|---|---|---|
Retrieving recent requests… | |||
Events —checkouts.CheckoutStarted
Why this endpoint exists
Two things happen here that only the platform can do.
It reserves inventory. The ticket provider is told to hold these products for this date, so the customer cannot lose their slot while filling in a form.
It prices the basket authoritatively. Per-day pricing, per-ticket booking costs, the flexible-date surcharge, and every discount the customer is entitled to — loyalty wallet, club membership, vouchers — are resolved server-side and returned as a complete priced basket. This response is what your review page renders. It is the only place the real total exists before payment.
Send an end-user token and those customer-specific discounts apply. Omit it and the same basket prices at full price for a guest.
Request
curl -X POST https://api.acc.funtrips.io/v2/checkouts \
-H "x-campaign-id: $CAMPAIGN_ID" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Authorization: Bearer $END_USER_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"date": "2026-09-12T10:30:00Z",
"flexible_date": false,
"external_reference": "cart-8817",
"lines": [
{ "product_id": "d4b8f2a1-6c3e-4957-b18d-2f5a9c7e3b04", "quantity": 2 },
{ "product_id": "b2c9e4f7-1a83-4d56-9e0b-7f3a1c8d5b62", "quantity": 1 }
],
"voucher_codes": ["WELCOME2026"]
}'| Field | Type | Required | Notes |
|---|---|---|---|
date | date-time | Yes | The visit date. Include the time component when any product needs a timeslot |
flexible_date | boolean | Yes | true buys the flexible-date option, letting the customer change the date later. Adds a per-ticket surcharge |
lines | array | Yes | At least one line |
lines[].product_id | UUID | Yes | From the catalog |
lines[].quantity | integer | Yes | Minimum 1 |
external_reference | string | No | Your own reference — a cart id. Echoed through the order for reconciliation |
voucher_codes | string[] | No | Up to 50 unlock-voucher codes. Invalid codes are silently ignored |
One basket, one merchant
Every line in a basket must belong to the same merchant.A basket spanning two merchants is refused with
400and
urn:qup:error:checkout-basket-spans-merchants. There is a second, narrower rule beneath it:
lines must also share one fulfillment provider (checkout-basket-spans-providers).The merchant rule is the one to design around, because it is stricter than it looks — two
different venues can share a provider, so a basket can pass the provider check and still be
refused. The reason is settlement: the merchant determines who gets paid, and a mixed basket
would credit one merchant for another's tickets.
If your cart lets customers collect experiences from several venues, split it at submission:
one checkout per merchant, each with its own Idempotency-Key. The customer then completes
several payments. Design the cart UI for that up front — discovering it at the payment step is a
much worse conversation than grouping the cart by venue from the start.
The date field carries the time
date field carries the timedate is a full timestamp, not a calendar day, and the time component is not decoration — ticket providers use it to resolve a capacity slot.
- Product has
requires_timeslot: true→ send the exactentry_fromof the slot the customer picked, fromPOST /fulfillment/timeslots. - Product has
no_date: true→ send today. - Otherwise → the chosen day; the time is not significant.
Never send midnight as a fallback for a timeslotted product.
2026-09-12T00:00:00Zis 02:00 Amsterdam time in summer. Timeslotted providers reject it, and when they do not, the booking lands on the wrong day. If the timeslot list for a date comes back empty for arequires_timeslotproduct, that date is unavailable — prompt for another one.
voucher_codes fail silently by design
voucher_codes fail silently by designEach valid code unlocks one product unit's campaign-defined discount. Invalid, expired and already-used codes are ignored without an error — the checkout still succeeds, just without that discount.
So you cannot tell from the status code whether a code applied. Compare the discounts array on the returned lines against the codes you sent, and if a customer's code did not land, tell them there rather than after payment. To validate a code before checkout, use POST /vouchers/validate.
Response
200 OK with the priced checkout.
{
"data": {
"checkout_id": "7c1b3f9a-2d4e-4a6f-9b1a-3c5e7d2f8a0b",
"status": "OPEN",
"date": "2026-09-12T10:30:00Z",
"locale": "nl-NL",
"expires_at": "2026-09-03T15:05:00Z",
"fulfillment_provider": "<provider>",
"requires_shipping_address": false,
"flexible_date": false,
"totals": {
"subtotal": { "value": "61.00", "currency": "EUR" },
"booking_costs": { "value": "2.97", "currency": "EUR" },
"flexible_ticket_costs": { "value": "0.00", "currency": "EUR" },
"discount_total": { "value": "11.00", "currency": "EUR" },
"grand_total": { "value": "52.97", "currency": "EUR" }
},
"line_items": [
{
"merchant_id": "8a2f4c61-9d3e-4b57-a0c8-1e5f7b2d9a30",
"merchant_title": "Amsterdam Zoo",
"product_id": "d4b8f2a1-6c3e-4957-b18d-2f5a9c7e3b04",
"product_title": "Dagticket volwassene",
"title": "Dagticket volwassene",
"quantity": 2,
"sku": "ZOO-ADULT-DAY",
"unit_price": { "value": "24.00", "currency": "EUR" },
"discounts": [
{ "type": "LOYALTY", "amount": { "value": "5.50", "currency": "EUR" }, "source": "wallet" }
],
"effective_price": { "value": "18.50", "currency": "EUR" },
"line_total": { "value": "37.00", "currency": "EUR" },
"product_age_group": "adult",
"product_conditions": ["Geldig op de gekozen datum."],
"merchant_logo_uuid": "e91f…",
"merchant_banner_uuid": "a37c…",
"fulfillment_provider_codes": { "<provider>": "PRV-4471" }
}
]
},
"meta": { "correlation_id": "…" }
}Fields to act on
| Field | Why it matters |
|---|---|
checkout_id | Needed for Confirm and for polling. For guests this is a secret — it is the only access control on the checkout |
expires_at | The reservation deadline. Drive a countdown from it |
requires_shipping_address | true → Confirm must carry a complete address. Authoritative; use this, not the product flag |
totals.grand_total | What the customer will be charged. Render this |
line_items[].discounts | Typed per-unit breakdown — LOYALTY, CLUB_MEMBERSHIP or VOUCHER, with the source. Empty when nothing applied |
line_items[].effective_price | Per-unit price after discounts: unit_price − Σ discounts.amount |
flexible_date | Whether the basket carries the flexible-date option |
payment_url is absent here. It only exists after Confirm.
Reading the totals
grand_total = subtotal + booking_costs + flexible_ticket_costs − discount_total
subtotal is the sum of line totals before surcharges. booking_costs and flexible_ticket_costs are per-ticket amounts already multiplied by the ticket count. discount_total is everything the customer saved.
Show the breakdown — customers ask why the total differs from the ticket prices, and booking_costs is usually the answer.
Errors
| Status | type | What to do |
|---|---|---|
400 | checkout-basket-empty | lines was empty |
400 | checkout-basket-line-invalid | A quantity is below 1 |
400 | checkout-basket-spans-providers | Basket mixes products from different fulfillment providers — split it |
400 | checkout-basket-spans-merchants | Basket mixes products from different merchants — split it, one checkout per merchant |
400 | checkout-date-in-past | Pick today or later |
400 | checkout-timeslot-required | A product needs a time of day; fetch timeslots and resend with a real time |
400 | checkout-locale-invalid | Accept-Language is unusable |
400 | — | Idempotency-Key missing or malformed |
401 | — | A token was sent and rejected as structurally invalid |
404 | campaign-not-found | The campaign is not configured — report it to us |
409 | — | Idempotency collision. See Idempotency |
422 | checkout-product-not-priceable | Product unavailable for that date — offer another date |
422 | checkout-product-not-fulfillable | Provider cannot fulfil it right now |
502 | checkout-reservation-placement-failed | The ticket provider could not be reached. Retryable with the same key |
502 | checkout-discount-resolution-failed | Discounts could not be resolved. Retryable |
500 | checkout-basket-pricing-failed, checkout-discount-allocation-failed, checkout-merchant-lookup-failed, checkout-debtor-code-unresolved | Server-side fault. Retry, then quote the correlation id |
208 | — | Idempotent replay of an earlier success. Treat as 200 |
The 422 pair is the customer-facing case worth handling well: the basket is fine but that date will not work. Send them back to the date picker with the calendar re-fetched, rather than showing an error.
Notes
checkouts.CheckoutStartedis published on success. If you consume platform events, that is your signal a basket was reserved.- Abandonment needs no call. An
OPENcheckout that is never confirmed expires by itself in 30 minutes and releases its reservation. - One retry key per basket. If the customer edits the basket and resubmits, that is a new operation — generate a new
Idempotency-Key, or you will get a409body mismatch.
