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
StatusMeaningWho moves it
CREATEDSession initialised, no reservation yetThe platform, during Create
OPENReservation placed and priced, ready to confirmThe platform, during Create
PENDING_PAYMENTConfirmed; customer is at the payment providerYour Confirm call
SUCCESSPayment captured, fulfillment runningThe payment provider, asynchronously
FAILEDPayment declined, or the reservation expiredThe 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:

  1. The provider captures the payment and notifies the platform.
  2. The checkout moves to SUCCESS.
  3. Fulfillment completes: barcodes are assigned, a ticket PDF is produced.
  4. 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_id is 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 the checkout_id and order_id yourself.

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.


Did this page help you?