Returns the tickets issued for a specific fulfillment order belonging
to the authenticated end-user, plus a short-lived presigned download
URL for the consolidated ticket PDF when one has been produced.
The order is resolved by fulfillment_order_id and must belong to
the JWT-authenticated user. If the order does not exist or it
belongs to a different user, 404 Not Found is returned without
distinguishing between the two — this prevents enumeration of order
IDs across users.
If the order exists and belongs to the user but tickets have not
yet been assigned (status PENDING / CONFIRMED), 409 Conflict
is returned with the current status so the caller can poll.
| Time | Status | User Agent | |
|---|---|---|---|
Retrieving recent requests… | |||
Part of ticketing — Tickets and fulfillment explains the order lifecycle and how tickets are issued.
Why this endpoint exists
The list endpoint gives you barcodes but no PDF. This one adds the deliverable: a short-lived presigned URL for the consolidated ticket PDF, generated on demand.
It is also the endpoint with a proper polling contract. While tickets are still being issued it returns 409 with a Retry-After header, which is a far better signal than the empty tickets array the list returns.
Which id goes here
This route takes thefulfillment_order_id, not the checkoutorder_id.They are different values, and the platform exposes two sibling routes that each accept one of them:
Route Id to use Where it comes from GET /me/fulfillment/orders/{fulfillment_order_id}/ticketsfulfillment_order_idThe list response GET /me/fulfillment/orders/{order_id}order_idConfirm checkout Passing the wrong one gets you a
404that looks exactly like a missing order. If you hold only the checkoutorder_id— which is the case right after purchase, and the only case for guests — use the sibling route.
Request
curl "https://api.acc.funtrips.io/v2/me/fulfillment/orders/$FULFILLMENT_ORDER_ID/tickets" \
-H "x-campaign-id: $CAMPAIGN_ID" \
-H "Authorization: Bearer $END_USER_TOKEN"Response
200 OK — the order, its tickets, and the deliverable when one exists.
{
"data": {
"fulfillment_order_id": "7c1b3f9a-2d4e-4a6f-9b1a-3c5e7d2f8a0b",
"order_id": "5e2a7c91-3b6d-4f82-a17c-9d4e6b1f3a25",
"status": "COMPLETED",
"total_price": { "value": "52.97", "currency": "EUR" },
"tickets": [
{
"fulfillment_line_id": "af31c8d2-59e4-4b17-8c6a-2d0f7e3b9145",
"product_title": "Dagticket volwassene",
"merchant_title": "Amsterdam Zoo",
"barcode": "3041234567890",
"symbology": "EAN_13",
"effective_price": { "value": "18.50", "currency": "EUR" },
"validity": { "…": "validity window" }
}
],
"deliverable": {
"file_name": "tickets-7c1b3f9a.pdf",
"file_type": "application/pdf",
"download_url": "https://s3.eu-west-1.amazonaws.com/…?X-Amz-Signature=…",
"expires_at": "2026-09-03T15:20:00Z"
}
},
"meta": { "correlation_id": "…" }
}download_url expires — fetch it now
download_url expires — fetch it nowIt is a presigned S3 URL with a short life, and expires_at is when it dies.
Stream the bytes immediately. Do not store the URL, e-mail it, or put it in a push notification — by the time anyone clicks, it is dead. If the customer needs the PDF again, call this endpoint again for a fresh URL.
deliverable is absent when no PDF has been produced. That is legitimate: not every order gets one — an order whose lines are all shipped physically has no ticket document. Fall back to rendering the barcodes.
Rendering barcodes
Each ticket carries barcode (the raw string as issued by the provider) and symbology (its encoding): QR, CODE128, CODE_39, EAN_13, PDF417, ITF, EAN_8, UPC_A, ITF_14, DATA_MATRIX.
Render barcode using the encoding named in symbology. Do not guess from the string's shape — a 13-digit value is not necessarily EAN_13, and a scanner at the gate will reject a mis-encoded barcode.
Match on the exact strings:CODE128carries no underscore whileCODE_39does.
The 409 polling contract
409 polling contractWhen the order exists and belongs to the customer but tickets are not assigned yet (PENDING / CONFIRMED), the response is 409 with a Retry-After header in seconds (typically 10).
HTTP/1.1 409 Conflict
Retry-After: 10
Content-Type: application/problem+json
Wait at least Retry-After, then retry with exponential backoff, capped around 5 minutes.
Read the delay from theRetry-Afterheader — it is set on every409. If you alsoneed the order's current lifecycle state, take it from the list endpoint.
async function fetchTickets(fulfillmentOrderId, campaignId) {
let delay = 10_000;
for (let attempt = 0; attempt < 8; attempt++) {
const res = await fetch(
`${BASE}/me/fulfillment/orders/${fulfillmentOrderId}/tickets`,
{ headers: { 'x-campaign-id': campaignId }, credentials: 'include' },
);
if (res.ok) return (await res.json()).data;
if (res.status !== 409) throw new Error(`unexpected ${res.status}`);
const retryAfter = Number(res.headers.get('Retry-After')) || 10;
await new Promise(r => setTimeout(r, Math.max(retryAfter * 1000, delay)));
delay = Math.min(delay * 1.5, 300_000);
}
return null; // still not ready — the e-mail will arrive regardless
}Give up gracefully. The ticket e-mail is sent independently of whether your client is still polling.
Errors
| Status | Meaning | What to do |
|---|---|---|
401 | Token missing, expired or invalid | Re-authenticate |
404 | No such order — or it belongs to another customer | See below |
409 | Tickets not ready yet | Honour Retry-After and retry |
500 | Unexpected fault | Retryable |
Fulfillment errors carry no type — branch on status.
404 deliberately hides ownership
404 deliberately hides ownershipAn order that exists but belongs to a different customer returns 404, identical to one that does not exist. The two are indistinguishable so that order ids cannot be enumerated across accounts.
Practically: a 404 does not prove the id is wrong. Check you are using the fulfillment_order_id and not the order_id, and that the token belongs to the customer who placed the order.
