Tickets and fulfillment
What happens after payment — how a ticket comes into being, how to show it, and how to change or re-send it.
Payment is not the end of the sale. Once the money is captured the platform still has to book with
the venue, get barcodes back, produce a ticket document, and put it in the customer's hands.
That work is a fulfillment order — a record with its own lifecycle, sitting alongside the
checkout that created it. Everything on this page is about reading and acting on that record.
The customer gets their tickets by e-mail either way. Delivery does not depend on your
storefront: if you build none of the screens below, the sale still completes and the tickets still
arrive. What these endpoints add is showing them in your own UI, and being able to help when
something goes wrong.
How a ticket comes into being
sequenceDiagram
participant C as Customer
participant P as Funtrips platform
participant V as Venue
C->>P: Pays
P->>P: Checkout → SUCCESS
P->>V: Confirm the booking
V-->>P: Barcodes
P->>P: Order → COMPLETED, ticket document produced
P->>C: Ticket e-mail
The gap between "paid" and "ticket in hand" is small but it is not zero, and it is not under
your control — it depends on the venue answering. Design for it: a customer returning from payment
will often arrive before their tickets exist.
The order lifecycle
| Status | Meaning |
|---|---|
CREATED | Registered, not started |
PENDING | Fulfillment running |
CONFIRMED | Booked with the venue, barcodes not assigned yet |
COMPLETED | Tickets issued. The only state where tickets are guaranteed present |
FAILED | Fulfillment could not complete |
CANCELLED | Cancelled before completion |
REFUNDED | Tickets voided and money returned |
REORDERED | Superseded by a date change — tickets voided, money kept, and a newer COMPLETED order exists for the same order |
Only COMPLETED guarantees tickets. PENDING and CONFIRMED return an empty tickets array,
which is a normal intermediate state and not an error — render "your tickets are being prepared".
FilterREORDEREDout of any "my tickets" view.After a date change the original order stays in the list in this
state, with its barcodes already voided. Show it and the customer sees a dead barcode next to
their live one.
Two ids, and this is the part people get wrong
A completed sale leaves you holding two different identifiers, and the endpoints are split
across them.
| Id | Where you get it | What it addresses |
|---|---|---|
order_id | Confirm checkout / Express | The order. Use it right after purchase |
fulfillment_order_id | List my orders | The fulfillment record |
| To do this | Call | With |
|---|---|---|
| Order confirmation page, straight after payment | GET /me/fulfillment/orders/{order_id} | order_id |
| "My tickets" list | GET /me/fulfillment/orders | — |
| One order's tickets + PDF | GET /me/fulfillment/orders/{fulfillment_order_id}/tickets | fulfillment_order_id |
The two single-order routes look almost identical and take different ids. Passing the wrong one
returns 404, indistinguishable from an order that does not exist — so if a lookup fails on an id
you are sure about, check which of the two you sent before anything else.
Right after checkout you only have order_id, which is why the by-order-id route exists at all.
Polling: 409 is the "not yet" answer
409 is the "not yet" answerBoth single-order routes answer 409 Conflict with a Retry-After header while the order
exists but its tickets do not.
HTTP/1.1 409 Conflict
Retry-After: 10
Wait at least that long, then back off exponentially and cap at around five minutes. On an order
confirmation page, expect several 409s before the first 200 — that is the normal path, not a
failure.
async function awaitTickets(orderId) {
let delay = 10_000;
for (let i = 0; i < 8; i++) {
const res = await fetch(`${BASE}/me/fulfillment/orders/${orderId}`, { credentials: 'include' });
if (res.ok) return (await res.json()).data;
if (res.status !== 409) throw new Error(`unexpected ${res.status}`);
const after = Number(res.headers.get('Retry-After')) || 10;
await new Promise(r => setTimeout(r, Math.max(after * 1000, delay)));
delay = Math.min(delay * 1.5, 300_000);
}
return null; // give up gracefully — the e-mail is on its way regardless
}Give up gracefully. After a reasonable window, show "your tickets are on their way by e-mail"
rather than spinning. The e-mail is sent independently of whether your page is still open.
What a ticket is
One ticket is one admission — a basket of two adult tickets and a child produces three.
| Field | Use |
|---|---|
barcode | The raw code as issued by the venue |
symbology | How to encode it: QR, CODE128, CODE_39, EAN_13, PDF417, ITF, EAN_8, UPC_A, ITF_14, DATA_MATRIX |
validity | When the ticket can be used |
product_title, merchant_title, product_age_group | What it admits, and where |
merchant_logo, ticket_image | Artwork, with DPR variants |
effective_price | What was paid for this admission |
Renderbarcodeusing the encoding named insymbology. Never infer it from the string.A thirteen-digit value is not necessarily
EAN_13, and a mis-encoded barcode is refused at the
gate — the worst possible place to discover a bug. Match the exact strings:CODE128carries no
underscore whileCODE_39does.
The PDF
GET .../{fulfillment_order_id}/tickets also returns a
deliverable — the consolidated ticket PDF, with a short-lived presigned download_url and an
expires_at.
Fetch the bytes immediately. Do not store that URL, e-mail it, or put it in a push notification —
it will be dead by the time anyone clicks. Need it again? Call the endpoint again for a fresh one.
deliverable is absent when no document was produced, which is legitimate: an order whose
lines are all shipped physically has no ticket to render. Fall back to displaying the barcodes.
The list endpoint never includes deliverable — pre-signing a URL
for every order on every page would mostly produce links nobody uses.
Guests have no order history
The /me/* routes resolve the customer from their session, so a guest purchase never appears
there. Guests receive their tickets by e-mail.
If you want an order page for a guest, keep the order_id from
Confirm against your own record and use it — or look the order up from your
backend, below.
From your backend
Three server-to-server endpoints cover the cases a storefront cannot: a support desk, a CRM view,
or fetching tickets for a purchase made without a session.
| Need | Endpoint | Scope |
|---|---|---|
| A customer's orders and tickets | GET /admin/fulfillment/users/{external_user_id}/orders | funtrips/fulfillment.read |
| One order's tickets and PDF | GET /admin/fulfillment/users/{external_user_id}/orders/{id}/tickets | funtrips/fulfillment.read |
| Re-send the ticket e-mail | POST /admin/fulfillment/orders/{id}/resend-ticket | funtrips/fulfillment.write |
They key on your external_user_id — the same value you passed to
POST /session. There is no separate platform user id to look up.
The resend endpoint is the one your support desk will actually use: "I never got my tickets", or a
mistyped address at checkout. It can send to the stored recipient or to an override.
These scopes are provisioned separately from the rest of your credential. If a call returns 403,
the token is valid but was not granted that scope.
Changing a visit date
A customer whose plans change does not need a refund and a repurchase: the visit date on a
COMPLETED order can be moved with
POST /fulfillment/reorder.
A date change is not an edit. It issues a new fulfillment order for the new date, voids the old
tickets, and charges the difference when the new date costs more. The customer ends up with fresh
barcodes, and the original order becomes REORDERED.
What it costs is two independent parts:
- The price difference for the new date, per ticket. Increases only — a cheaper date is not
refunded. - The campaign's date-change fee, from
date_change_costin
campaign config. Waived if the customer bought the flexible-date
option — that waives the fee, never the price difference. Say so in your UI, or customers who
paid for flexibility will be surprised by a charge.
When the total is zero, amount_due and payment_url come back null and the new tickets are
issued straight away. Otherwise redirect to payment_url; tickets are issued only after payment.
Branch on payment_url rather than assuming either case.
Two limits worth building around: the visit date must be tomorrow or later (same-day changes
are refused), and only one unpaid change can be outstanding per order. A change awaiting payment
can be abandoned with
DELETE /fulfillment/reorder/{fulfillment_order_id} — though
starting a new one supersedes it automatically, so cancelling first is optional. Once the
difference is captured it can no longer be cancelled.
Poll for the new tickets with
GET /me/fulfillment/orders/{order_id} using the original
order_id — it resolves to whichever order is current for that id.
Related
- The checkout lifecycle — everything up to payment
- List my orders · order tickets · order by order id
- Error handling — fulfillment routes answer with status and title, so branch on the status code
Updated about 2 hours ago
