Supports guest users.
Confirms a checkout by providing customer details and a payment return URL.
Returns a PSP payment URL to redirect the customer to. After payment the PSP
redirects back to return_url. Supports idempotency via the
Idempotency-Key header.
Events fired
| Event | Trigger | Description |
|---|---|---|
checkouts.CheckoutFulfillmentRegistered | sync | Fired when the checkout is confirmed and a fulfillment workflow has been registered. |
| Time | Status | User Agent | |
|---|---|---|---|
Retrieving recent requests… | |||
Events —checkouts.CheckoutFulfillmentRegistered
Why this endpoint exists
This is the commitment point. Three things happen, in order, and all three have to succeed:
- The customer's details are attached to the checkout — this is where the ticket recipient is finally known.
- The fulfillment workflow starts, so tickets will be issued once payment clears.
- A payment is created at the provider, and its hosted payment page URL comes back.
After this call the customer is going to be charged. It is the one endpoint where getting idempotency right actually protects someone's money.
Request
curl -X POST "https://api.acc.funtrips.io/v2/checkouts/$CHECKOUT_ID/confirm" \
-H "x-campaign-id: $CAMPAIGN_ID" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"first_name": "Sanne",
"last_name": "de Vries",
"return_url": "https://tickets.your-brand.example/order/complete",
"newsletters": ["monthly-deals"]
}'| Field | Type | Required | Notes |
|---|---|---|---|
email | Yes | Where the tickets are sent. See below | |
first_name | string | Yes | |
last_name | string | Yes | |
return_url | uri | Yes | Where the provider sends the customer after payment |
newsletters | string[] | No | Newsletter ids the customer opted into |
street | string | Conditional | Required when requires_shipping_address is true |
house_number | string | Conditional | Required when requires_shipping_address is true |
house_number_suffix | string | No | e.g. A, bis |
postal_code | string | Conditional | Required when requires_shipping_address is true |
city | string | Conditional | Required when requires_shipping_address is true |
country_code | string | Conditional | ISO 3166-1 alpha-2, e.g. NL. Required when requires_shipping_address is true |
email is the delivery address, not the account
email is the delivery address, not the accountTickets and the order confirmation go to this address — the one the customer typed on your form. For a signed-in customer that may not be the address on their account, and when it differs, this one wins.
That is intentional: people buy tickets for other people, and buy from a work account for a personal trip. Pre-fill the field from the session when you have one, but let them change it, and show them what they typed before they commit. A typo here means tickets delivered to nobody.
The shipping address is conditional and strict
Read requires_shipping_address from the Create response. When it is true the basket contains a physical product, and the address is validated as a set: street, house_number, postal_code, city and country_code must all be present. A partial address is rejected with checkout-shipping-address-required.
When it is false, address fields are ignored — you can send them or omit them.
return_url is a return, not a result
return_url is a return, not a resultThe provider redirects the customer here after they finish, whatever the outcome — paid, cancelled, or abandoned mid-flow. It carries no trustworthy status.
Treat landing on return_url as "the customer came back", then ask the platform what actually happened via GET /checkouts/{checkout_id}. Never grant tickets or show a success page based on the redirect alone.
Response
200 OK:
{
"data": {
"order_id": "5e2a7c91-3b6d-4f82-a17c-9d4e6b1f3a25",
"order_display_id": "FT-2026-004471",
"payment_url": "https://checkout.psp.example/pay/abc123"
},
"meta": { "correlation_id": "…" }
}| Field | Use |
|---|---|
payment_url | Redirect the customer here immediately. Do not fetch, proxy or iframe it |
order_id | The platform order id. Persist it — it is how you look up tickets later, including for guests |
order_display_id | Human-readable reference for order confirmations and support |
Store order_id and order_display_id against your own record before redirecting. If the customer closes the tab on the payment page, those two values are the only handle you have on the order.
Errors
| Status | type | What to do |
|---|---|---|
400 | checkout-shipping-address-required | Address incomplete for a physical basket |
400 | checkout-timeslot-required | A product needs a visit time |
400 | — | Missing email / first_name / last_name / return_url, or no Idempotency-Key |
401 | — | A token was sent and rejected as structurally invalid |
404 | checkout-not-found | Unknown checkout_id, or it belongs to another customer |
404 | checkout-reservation-expired | The 30-minute window elapsed. Start a new checkout |
404 | campaign-not-found | Campaign not configured — report it to us |
409 | — | Idempotency collision. Retry-After present → in-flight, retry same key. Absent → body mismatch, use a new key |
422 | checkout-reservation-invalid | Reservation no longer valid. Start a new checkout |
422 | checkout-billing-detail-rejected | Billing/shipping details rejected — unknown country, invalid e-mail |
502 | checkout-order-failed | Payment system could not confirm. Retry with the same key |
502 | checkout-order-empty-redirect | Payment system returned no URL. Retry with the same key |
502 | checkout-fulfillment-reservation-failed | Fulfillment could not start. Retry with the same key |
500 | — | Server-side fault. Retry with the same key, then quote the correlation id |
208 | — | Idempotent replay. Same payment_url and order_id. Treat as 200 |
Expiry is the error you will see most
checkout-reservation-expired and checkout-reservation-invalid both mean the reservation is gone, and both are unrecoverable at this endpoint — there is no extend call. Re-run Create with the same basket, which re-checks availability, then confirm again.
Handle it as a flow, not an error: "your reservation expired, we're re-checking availability" beats a stack trace. Expect it whenever a customer leaves a tab open.
On 502, retry — do not re-issue
502, retry — do not re-issueA 502 means we reached the payment provider and something went wrong. The order may or may not exist.
Retry the identical request with the identical Idempotency-Key. You will get either a 208 with the original payment_url (the first attempt worked, the reply was lost) or a clean re-attempt.
Never retry a confirm with a freshIdempotency-Key.A new key is a new operation. If the first attempt did succeed, you have now created a second order and a second payment for the same basket, and the customer can be charged twice.
Notes
- Idempotency guards double-submits too. A customer double-clicking "Pay" produces two identical requests; the same key makes the second a
208and they are redirected to the same payment page. Reuse one key for the whole confirm intent. - A fast payment can beat your poll. The platform re-reads the checkout before writing
PENDING_PAYMENT, so a provider that captures before Confirm returns cannot be overwritten back to a non-terminal state. AGETright after Confirm may legitimately showSUCCESSalready. - If Confirm fails after fulfillment started, the platform cancels the fulfillment workflow itself. You do not compensate.
checkouts.CheckoutFulfillmentRegisteredis published on success.
