The checkout lifecycle
How a basket becomes a paid order with tickets — the five states and who moves them.
Checkout is the part of the integration where money and inventory are at stake, so it is worth understanding the shape before writing against the endpoints.
The design point is this: the platform owns pricing, reservation, payment and ticket issuance. You own the basket the customer assembled and the details they typed. You never compute a total, never talk to the payment provider, and never talk to the ticket provider.
The five states
stateDiagram-v2
[*] --> CREATED: POST /checkouts
CREATED --> OPEN: reservation placed
OPEN --> PENDING_PAYMENT: POST /confirm
PENDING_PAYMENT --> SUCCESS: payment captured
PENDING_PAYMENT --> FAILED: payment declined
OPEN --> FAILED: 30-minute expiry
SUCCESS --> [*]: tickets issued
| Status | Meaning | Who moves it |
|---|---|---|
CREATED | Session initialised, no reservation yet | The platform, during Create |
OPEN | Reservation placed and priced, ready to confirm | The platform, during Create |
PENDING_PAYMENT | Confirmed; customer is at the payment provider | Your Confirm call |
SUCCESS | Payment captured, fulfillment running | The payment provider, asynchronously |
FAILED | Payment declined, or the reservation expired | The payment provider, or the clock |
You will normally see OPEN from Create and PENDING_PAYMENT from Confirm. CREATED is transient.
The two-step flow
sequenceDiagram
participant C as Customer
participant Y as Your storefront
participant P as Funtrips API
participant PSP as Payment provider
C->>Y: Adds tickets, picks a date
Y->>P: POST /checkouts (basket + date)
P-->>Y: OPEN + priced totals + expires_at
Y->>C: Review page — server totals
C->>Y: Name, e-mail, confirms
Y->>P: POST /checkouts/{id}/confirm
P-->>Y: order_id + payment_url
Y->>C: Redirect to payment_url
C->>PSP: Pays
PSP-->>C: Redirect to your return_url
PSP->>P: Payment captured (async)
P->>P: SUCCESS → fulfillment → tickets by e-mail
Y->>P: GET /checkouts/{id} → SUCCESS
Create takes the basket and the visit date, places a reservation with the ticket provider, prices everything including the customer's discounts, and returns the full priced basket. This is what your review page renders.
Confirm takes the customer's details and a return_url, starts fulfillment, creates the payment, and returns a payment_url. You redirect the customer there.
Then you wait. Payment completion arrives asynchronously from the provider; the customer comes back to your return_url — possibly before the platform has heard from the provider, possibly after. Poll GET /checkouts/{id} until the status is terminal.
One basket, one merchant
A checkout covers a single merchant. Lines spanning two merchants are refused at Create with
checkout-basket-spans-merchants, because the merchant is who gets paid and a mixed basket would
settle to the wrong one.
If your cart spans venues, split it into one checkout per merchant at submission time. See
Create checkout for the detail.
The one-step alternative
Express checkout collapses Create and Confirm into a single call: basket, date, customer details and return_url together, payment_url straight back.
Use it when your UI has no review step — when the customer submits everything at once and expects to land on the payment page. It is leaner, because it skips building a priced review nobody is going to look at.
Use the two-step flow when you want to show the customer the server's totals before they commit. That is the honest reason to prefer it: discounts from wallet, vouchers and memberships are applied server-side, so the two-step Create response is the only way to display the real price before payment.
Fulfillment starts at Confirm, not Create
Create places a reservation; Confirm starts the fulfillment workflow that will issue actual tickets.
The reason is the customer's e-mail address. Tickets are delivered to the person who bought them, and for guest checkout the e-mail only exists once they type it into the confirm form. Starting fulfillment earlier would mean issuing tickets with nowhere to send them.
The consequence for you: a checkout abandoned at OPEN has cost nothing and issued nothing. It expires on its own. There is no cancel call to make.
The 30-minute window
A reservation lives 30 minutes from Create. expires_at on the checkout is authoritative — display a countdown from it rather than assuming the duration.
Past expiry, Confirm fails with urn:qup:error:checkout-reservation-expired (404) or urn:qup:error:checkout-reservation-invalid (422). Both mean the same thing operationally: start a new checkout. There is no extend or refresh; the reservation is gone from the provider's inventory and the customer may need to pick a different date.
Design for it. A customer who leaves a payment page open over lunch will come back to a dead checkout, and the recovery you want is "we saved your basket, let's re-check availability", not an error page.
What happens after payment
Nothing you have to drive:
- The provider captures the payment and notifies the platform.
- The checkout moves to
SUCCESS. - Fulfillment completes: barcodes are assigned, a ticket PDF is produced.
- The customer receives an order confirmation and their tickets by e-mail.
If payment fails, the checkout goes to FAILED and fulfillment is cancelled automatically — the reservation is released back to the provider.
Your storefront's job after the redirect is to reflect status. Poll GET /checkouts/{id} until it reaches a terminal state — from there Tickets and fulfillment picks up the story, through issuance, showing barcodes, and re-sending a lost e-mail.
Guest versus signed-in
Both work through the same endpoints. Sending an end-user token gets the customer their loyalty balance, memberships and login-gated discounts; sending nothing sells at full price.
Two differences matter:
- A guest checkout has no owner, so the
checkout_idis the only thing protecting it. Anyone holding that id can read the checkout. Treat it as a secret: keep it out of URLs you log, analytics, and referrer headers. - A guest's orders are not in
/me/*. Those routes resolve the customer from the JWT, so a guest has no order history. Deliver their tickets by e-mail and, if you want an order status page, remember thecheckout_idandorder_idyourself.
Money
Every amount is {"value": "12.50", "currency": "EUR"} — a decimal string. Parse it into a decimal type, never a float.
Do not recompute totals client-side to check them. Booking costs are per ticket, the flexible-date surcharge is per ticket, discounts are allocated across lines by a server-side allocator, and totals.grand_total is the number the customer will be charged. Render it; do not verify it.
Updated 21 minutes ago
