Session exchange

Hand a customer who is already signed in on your platform to the Funtrips storefront, without a second login.

Session exchange is how a retailer delegates identity. Your customer is already signed in to your app or website; you do not want to send them through another login, and you certainly do not want to share their password or your session cookie with us.

So instead: your backend asks for a one-time code, and your front-end spends it.

The code is the whole trick. It is issued to your server over an authenticated S2S channel, it is worthless after one use, it expires in minutes, and it is the only thing that ever travels through the customer's browser. Your credentials never leave your backend, and we never learn the customer's password.

The flow

sequenceDiagram
    participant C as Customer's browser
    participant R as Your backend
    participant P as Funtrips API

    C->>R: Signed in, opens "Tickets & deals"
    R->>P: POST /session (S2S token, external_user_id)
    Note over R,P: Authorization: Bearer <S2S><br/>Idempotency-Key: <uuid>
    P-->>R: code (43 chars, single-use) + webframe host
    R-->>C: Redirect to {webframe}?{param}={code}
    C->>P: POST /session/token { code }
    P-->>C: Access + refresh token, and HttpOnly cookies
    Note over C,P: Code is now dead. Session is live.
    C->>P: GET /merchants, POST /checkouts …

Four steps, two of them yours.

Step 1 — Your backend requests a code

Call POST /session with your S2S token. You send the identifier you use for this customer; the campaign is read from your credentials, so there is nothing to pass.

curl -X POST https://api.acc.funtrips.io/v2/session \
  -H "Authorization: Bearer $S2S_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Accept-Language: nl-NL" \
  -H "Content-Type: application/json" \
  -d '{
    "external_user_id": "customer-4815162342",
    "ttl_seconds": 300,
    "integration_context": {
      "loyalty_points_balance": 1250,
      "tier": "gold"
    }
  }'
{
  "data": {
    "session_id": "9f8e7d6c-5b4a-4938-8271-6a5b4c3d2e1f",
    "campaign_id": "3c1e5a90-7b2d-4f61-9a83-1d4e7f2b8c05",
    "code": "s7Qk2mXpR4vY8nB1tL6wZ3hJ9dF0gC5aE2iU7oS4rN8",
    "code_expires_at": "2026-09-03T14:35:00Z",
    "webframe": {
      "url": "https://tickets.your-brand.example",
      "query_param": "code"
    }
  },
  "meta": { "correlation_id": "…" }
}

Three things here are worth understanding properly.

external_user_id is your identifier, stored verbatim. There is no separate platform user id for you to look up and no mapping table to maintain. Whatever you send becomes the canonical reference for this customer across their orders, tickets and loyalty balance. Send a stable internal id — a customer number or account UUID. It is usually an email, but it does not have to be, and if you can send something that does not change when the customer changes their email address, do that instead.

ttl_seconds should be short. The default is 300 (5 minutes) and the accepted range is 60–900. The code only has to survive one redirect, so there is no reason to stretch it. Shorter is strictly safer.

integration_context is your side-channel. A free-form JSON bag that we store on the session and hand to the integration adapter built for your campaign. This is where a loyalty balance, a customer tier, or a partner reference goes — anything the platform needs from your system at session-creation time, that it has no other way to know. The keys that mean something are agreed per integration; unknown keys are stored and ignored. Values may be any JSON type.

Step 2 — Your backend redirects the customer

Compose the destination from the webframe block and send the customer there:

https://tickets.your-brand.example?code=s7Qk2mXpR4vY8nB1tL6wZ3hJ9dF0gC5aE2iU7oS4rN8

The webframe.url is resolved from your campaign configuration, so it follows your environment without a client-side change. webframe.query_param names the parameter that carries the code — read it rather than hardcoding code, and you will survive a config change. Deep-linking to a specific page inside the storefront is fine: append your path to url before the query string.

If your campaign has no webframe configured, the webframe block is simply absent from the response — you are hosting the storefront yourself and already know where to send the customer.

🚧

The code is a credential in a URL

Treat the redirect as sensitive. Use HTTPS, do not log the composed URL, and do not put the code anywhere it will be retained — a server access log, an analytics pageview, a referrer header. The short TTL and single use limit the damage, but the cleanest posture is that the code exists only in the redirect itself.

Step 3 — The front-end spends the code

The storefront reads the code off its own URL and posts it to POST /session/token. No authentication — possession of the code is the authentication.

curl -X POST https://api.acc.funtrips.io/v2/session/token \
  -H "Content-Type: application/json" \
  -d '{ "code": "s7Qk2mXpR4vY8nB1tL6wZ3hJ9dF0gC5aE2iU7oS4rN8" }'
{
  "data": {
    "access_token": "eyJraWQ…",
    "refresh_token": "eyJjdHki…",
    "token_type": "Bearer",
    "expires_in": 3600,
    "campaign_id": "3c1e5a90-7b2d-4f61-9a83-1d4e7f2b8c05",
    "locale": "nl-NL",
    "integration_context": { "loyalty_points_balance": 1250, "tier": "gold" }
  },
  "meta": { "correlation_id": "…" }
}

The response also sets access_token and refresh_token as HttpOnly, Secure, SameSite=None cookies. A browser storefront can stop there and let the cookies carry the session; it never has to touch the token in JavaScript. A native app uses the body values.

locale echoes the language negotiated from the Accept-Language you sent in step 1 — switch the UI to it. integration_context is handed back so the front-end can confirm what the platform is operating against.

The code dies here. It is invalidated the instant it is exchanged. A second attempt returns 409 Conflict with urn:qup:error:authorization-code-already-redeemed.

Step 4 — Use the session

From here the storefront is authenticated. The token (or cookie) goes on every /me/* call, unlocks personalised pricing at checkout, and identifies the customer for loyalty and vouchers. Refresh it with POST /session/token/refresh before it expires.

Failure modes

Each of these is a distinct, machine-readable outcome. Handle them separately — they need different responses from your storefront.

WhereStatustypeWhat happenedWhat to do
POST /session401S2S token missing, expired or invalidRe-fetch the token, retry once
POST /session403Token valid but lacks funtrips/session.createProvisioning issue — contact us
POST /session400external_user_id missing/too long, ttl_seconds out of rangeFix the request
POST /session409Idempotency-Key reuse: in-flight, or same key with a different bodySee Idempotency
POST /session/token404urn:qup:error:authorization-code-not-foundCode never existed, or expiredRestart at step 1
POST /session/token409urn:qup:error:authorization-code-already-redeemedCode was already spentRestart at step 1
POST /session/token400code absent or not 43 charactersCheck your URL parsing

The two POST /session/token failures are the ones your users will actually hit — a bookmarked storefront URL containing a stale code, or a double-submitted page. Both mean the same thing to the customer: silently restart the handoff. Send them back to your own entry point, which requests a fresh code and redirects again. Never show them the error.

Two ways to spend the code

Step 3 assumes you host the storefront and exchange the code yourself. There is a second mode, and
which one you are on is decided by your campaign's configuration rather than by anything in the
request.

HeadlessWebframe
Who exchanges the codeYour front-endThe Funtrips web app
Who holds the sessionYour front-endThe frame, internally
You implementSteps 1–4Steps 1–2 only
Storefront UIYoursFuntrips white-label

Headless is the flow above: you own the storefront, you call
POST /session/token, and you hold the resulting session.

Webframe hands that off. You still request a code in step 1 and still compose the redirect in
step 2 — but the destination is the Funtrips web app, which redeems the code and keeps the session
in its own context. You never call the token exchange and never handle a customer token. If your
campaign is configured for it, POST /session returns a webframe block and that is the mode you
are on.

You can also combine them: read the catalog through the API to build your own browsing experience,
then deep-link into the webframe to complete a purchase. Append your path to webframe.url before
the query string.

No PII has to reach us

external_user_id is an opaque reference of your choosing. Nothing requires it to be an
e-mail address, or to be derivable into one — a customer number or an internal UUID works exactly
the same way, and is the better choice because it does not change when a customer changes their
e-mail.

That makes a fully anonymous integration possible: mint the session with an opaque key, and the
only thing you have told us about the customer is a string that means something in your systems
and nothing in ours. Their catalog browsing, their loyalty balance and their orders all hang off
that key.

Two things to know if you take that route:

  • Your key becomes the customer's identity here, everywhere — orders, tickets, wallet. Keep the
    mapping on your side and keep the key stable.
  • A purchase still needs a delivery address. Confirm checkout takes the
    customer's name and e-mail because that is where the tickets are sent. If you want no customer
    contact details in our system at all, talk to us — that changes how tickets are delivered.

Why not just call /session/token from the backend?

Because then you would hold the customer's tokens and have to forward them, and the customer's browser would need a way to receive them from you — which is the problem the code already solves, moved one step later. Splitting it this way means the S2S credential stays on your server, the customer's session stays in their browser, and the only shared secret is single-use and expires in minutes.

Campaigns that federate to your own identity provider

A campaign can instead be configured to accept an identity token issued by your identity
provider, exchanged directly for a session — no POST /session call and no code, because the token
you already hold is the identity proof.

That is arranged per campaign. If yours is set up that way we give you the exchange details during
onboarding; otherwise the hand-off on this page is the one to build.


Did this page help you?