Credentials and tokens
The three credentials a retailer integration uses, and which endpoints accept each.
The platform issues tokens from Cognito, and every protected route validates a bearer JWT. A retailer integration deals with at most three credentials, and which one you send depends entirely on who the request is on behalf of.
| Credential | Represents | Obtained by | Sent as |
|---|---|---|---|
| S2S token | Your backend, acting as the retailer | OAuth 2.0 client_credentials | Authorization: Bearer <token> |
| End-user token | One signed-in customer | Session exchange or magic link | Authorization: Bearer <token> |
| No credential | An anonymous visitor / guest buyer | — | Nothing, or an expired token |
There is also an AWS SigV4 scheme guarding /internal/*. Retailers never use it. If you have not read the overview yet, start there.
Which endpoints need what
The three groups behave differently enough that it is worth internalising the split before you write any code.
Public — no credential at all. The whole catalog, campaign config, timeslots, voucher validation, and analytics ingestion. Scope comes from the x-campaign-id header, not from a token. These are safe to call from a browser.
Optional credential — guest-capable. The checkout routes and payment lookup. Send an end-user token and the customer gets their loyalty balance, memberships and login-gated discounts applied; send nothing and the same call succeeds as a guest at full price. A malformed or expired token is treated as "guest", not as an error — so a silently expired session degrades to guest pricing rather than a 401. If applying loyalty matters to you, verify the session with GET /me before building the basket.
Required credential. Everything under /me/*, plus voucher redemption and date changes, needs a valid end-user token. POST /session needs an S2S token. A missing or invalid token here is a 401.
Each endpoint page states its requirement in a table at the top.
Getting an S2S token
Your backend authenticates once with the OAuth 2.0 client credentials grant and reuses the token until it is near expiry.
curl -X POST https://auth.qup.systems/oauth2/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=$QUP_CLIENT_ID" \
-d "client_secret=$QUP_CLIENT_SECRET"{
"access_token": "eyJraWQ…",
"expires_in": 3600,
"token_type": "bearer"
}Then on every call:
Authorization: Bearer eyJraWQ…
Cache the tokenTokens live one hour. Fetch a new one when the current token has less than ~60 seconds left, not on every request — the token endpoint is rate-limited and a per-request token fetch doubles your latency for no benefit. Never ship
client_secretto a browser or mobile app; the client credentials grant belongs on your server.
Scopes
Your client is provisioned with the scopes your integration needs. The one that matters for a retailer handing over its own customers is:
| Scope | Grants |
|---|---|
funtrips/session.create | Create and exchange session authorization codes — session exchange |
funtrips/wallet.write | Award and spend loyalty tokens for any customer in your campaign — loyalty wallet |
Other funtrips/* scopes exist for back-office and internal use. If a call returns 403, your token is valid but lacks the scope the route requires; that is a provisioning change on our side, not something you can fix in the request.
Your campaign is in the token
The S2S token carries a campaign claim, resolved from your client_id. That is why POST /session takes no campaign parameter — your credentials already say which campaign you are. One client_id maps to exactly one campaign; if you run several campaigns you get several client ids.
The end-user token
An end-user token is minted for one customer, in one campaign, and carries:
| Claim | Meaning |
|---|---|
sub | The platform's user id for this customer |
external_user_id | Your identifier for them, stored verbatim |
campaign | Campaign scope, inherited from the session |
locale | The negotiated language at session creation |
integrator_params | Opaque values you attached, passed through untouched |
Access tokens are short-lived (≤ 60 minutes). Every mint endpoint also sets access_token and refresh_token HttpOnly cookies (Secure, SameSite=None) alongside the JSON body, so a browser front-end can rely on cookies and never touch the token in JavaScript. Native apps use the body values and store them in the platform keychain.
Refresh with POST /session/token/refresh before expiry; end the session with POST /auth/logout.
Two ways to get a customer signed in
flowchart TD
A[Customer is signed in<br/>on your platform] -->|You have your own accounts| B[Session exchange<br/>POST /session → POST /session/token]
C[Customer has no account<br/>with you] -->|We e-mail them a link| D[Magic link<br/>POST /auth/link/create → POST /auth/link/verify]
B --> E[End-user token + cookies]
D --> E
Use session exchange when your customers are already logged in to your platform — it is the flow this API was designed around, and the one you almost certainly want. Use magic link when the platform must establish identity itself, by mailing a one-time link.
Both end at the same place: an end-user token scoped to your campaign.
Updated about 1 hour ago
