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

StatusMeaning
CREATEDRegistered, not started
PENDINGFulfillment running
CONFIRMEDBooked with the venue, barcodes not assigned yet
COMPLETEDTickets issued. The only state where tickets are guaranteed present
FAILEDFulfillment could not complete
CANCELLEDCancelled before completion
REFUNDEDTickets voided and money returned
REORDEREDSuperseded 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".

Filter REORDERED out 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.

IdWhere you get itWhat it addresses
order_idConfirm checkout / ExpressThe order. Use it right after purchase
fulfillment_order_idList my ordersThe fulfillment record
To do thisCallWith
Order confirmation page, straight after paymentGET /me/fulfillment/orders/{order_id}order_id
"My tickets" listGET /me/fulfillment/orders
One order's tickets + PDFGET /me/fulfillment/orders/{fulfillment_order_id}/ticketsfulfillment_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

Both 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.

FieldUse
barcodeThe raw code as issued by the venue
symbologyHow to encode it: QR, CODE128, CODE_39, EAN_13, PDF417, ITF, EAN_8, UPC_A, ITF_14, DATA_MATRIX
validityWhen the ticket can be used
product_title, merchant_title, product_age_groupWhat it admits, and where
merchant_logo, ticket_imageArtwork, with DPR variants
effective_priceWhat was paid for this admission

Render barcode using the encoding named in symbology. 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: CODE128 carries no
underscore while CODE_39 does.

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.

NeedEndpointScope
A customer's orders and ticketsGET /admin/fulfillment/users/{external_user_id}/ordersfuntrips/fulfillment.read
One order's tickets and PDFGET /admin/fulfillment/users/{external_user_id}/orders/{id}/ticketsfuntrips/fulfillment.read
Re-send the ticket e-mailPOST /admin/fulfillment/orders/{id}/resend-ticketfuntrips/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_cost in
    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


Did this page help you?