Access and tokens
How a merchant obtains an API credential, exchanges it for a token, and what that token is allowed to read.
Two kinds of identity can read a merchant's figures. They end at the same token, but they start in different places.
| Principal | Represents | Obtained by | Signs in with |
|---|---|---|---|
| API credential | A system of yours — bookkeeping, BI, a scheduled job | Issued by QueueUp, on request | client_id + client_secret, exchanged at POST /merchant-accounts/credentials/token |
| Portal user | A person on your side | Invited by QueueUp, by e-mail | A one-time sign-in link, exchanged at POST /merchant-accounts/session/token |
Both produce a merchant token: a bearer JWT that names the debtor codes it may read. If you are integrating, you want the API credential. The portal flow is here so you know how a portal user on your side signs in.
Which endpoints need what
Public — no credential. The token endpoint itself and the sign-in-link request. Possession of the credential, or of the e-mailed code, is the authentication.
Merchant token required. The sales report. A missing or expired token is a 401; a valid token asking for a debtor code it does not cover is a 403.
Nothing on the merchant surface takes a retailer's S2S token or a campaign header. Merchants are not campaign-bound — you sell through many campaigns, and your report spans all of them.
Getting an API credential
You cannot mint a credential yourself. QueueUp issues one for you, naming the debtor codes it may access and a label so you can tell credentials apart later (bookkeeping-prod, bi-nightly). You receive:
client_id | Public identifier, prefixed s2s_. Safe to log. |
client_secret | Shown once, at issuance. Never retrievable again. |
Store the secret in a secrets manager, not in source control and not in a browser or mobile app. If it is lost, the credential is disabled and a new one issued — there is no reset.
Ask for one credential per system. When the bookkeeping export and the dashboard share a secret, rotating one means redeploying the other, and a leak cannot be attributed.
Exchanging it for a token
The exchange is a client-credentials grant against POST /merchant-accounts/credentials/token. Authenticate with HTTP Basic:
curl -X POST https://api.acc.funtrips.io/v2/merchant-accounts/credentials/token \
-u "$MERCHANT_CLIENT_ID:$MERCHANT_CLIENT_SECRET" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials"{
"access_token": "eyJraWQ…",
"token_type": "Bearer",
"expires_in": 900
}Or put the credentials in the body — form-encoded or JSON, both are accepted:
curl -X POST https://api.acc.funtrips.io/v2/merchant-accounts/credentials/token \
-H "Content-Type: application/json" \
-d '{ "client_id": "'"$MERCHANT_CLIENT_ID"'", "client_secret": "'"$MERCHANT_CLIENT_SECRET"'" }'Three rules, all of which come from the OAuth 2.0 token-endpoint contract:
- Pick one place for the credentials. Basic auth or the body, never both. Sending both is a
400 invalid_request. grant_typeis optional, but if present it must beclient_credentials. Anything else is a400 unsupported_grant_type.scopeis accepted and ignored. The token's permissions come from the credential, not the request.
Both halves of the credential are URL-safe base64, so a client_id:client_secret Basic header needs no escaping.
The response is the bare OAuth token object — no data/meta envelope — with Cache-Control: no-store. That is deliberate: it lets a standard OAuth client library obtain the token unchanged.
Then on every call:
Authorization: Bearer eyJraWQ…
Cache the tokenMerchant tokens are short-lived — read
expires_inrather than assuming a value; today it is fifteen minutes. Reuse the token until it has less than a minute left, then fetch a new one. There is no refresh token on this flow: a new exchange is the refresh. Fetching a token per request doubles your latency and buys nothing.
What the token carries
A merchant token is issued from the merchant identity pool and carries, alongside the standard JWT claims:
| Claim | Meaning |
|---|---|
debtor_codes | The merchants this principal may read. The report endpoint checks every request against it. |
scope | merchant/reports.read — present whenever the principal covers at least one debtor code |
user_id | The principal's own identifier: the client_id for a credential, the e-mail address for a portal user |
You never need to decode it. But it explains two behaviours on the report endpoint: debtor_code is optional when the token names exactly one merchant, and a 403 means you asked for one it does not name.
Token endpoint errors
The token endpoint speaks OAuth, so its errors are {"error": "<code>"} with an HTTP status — not RFC 7807 problem documents.
| Status | error | What happened | What to do |
|---|---|---|---|
400 | invalid_request | Malformed body, or credentials sent both in Basic auth and in the body | Fix the request |
400 | unsupported_grant_type | grant_type present and not client_credentials | Send client_credentials or omit it |
401 | invalid_client | Unknown client_id, wrong secret, or a credential that has been disabled | Check the secret; if it is right, the credential was revoked — contact us |
The 401 carries WWW-Authenticate: Basic realm="merchant-token", as the specification requires. It is deliberately uniform: an unknown client, a wrong secret and a disabled credential are indistinguishable from outside, so nobody can probe which client ids exist. Do not retry a 401 in a loop — the answer will not change until the credential does.
Rotation and revocation
Credentials do not expire. Rotate on your own schedule, or immediately if a secret may have leaked:
- Ask QueueUp to issue a new credential for the same debtor codes.
- Deploy the new secret and confirm it exchanges.
- Ask QueueUp to disable the old one.
Disabling is permanent and immediate for new exchanges. A token already minted from the old credential keeps working until it expires — another reason token lifetimes are short.
QueueUp can also tell you, at any time, every principal with access to one of your debtor codes: each API credential by label and client id, each portal user by e-mail, with its status. Ask when you audit access.
Portal users: the sign-in link
People do not get a client secret. A portal user is invited by e-mail, and signs in by asking for a one-time link:
sequenceDiagram
participant U as Portal user
participant P as Merchant portal
participant A as Funtrips API
U->>P: Enters e-mail address
P->>A: POST /merchant-accounts/session/link { email }
A-->>P: 202 Accepted (always)
A-->>U: E-mail with a single-use code
U->>P: Opens the link
P->>A: POST /merchant-accounts/session/token { code }
A-->>P: Merchant token
P->>A: GET /merchant-accounts/reports/sales
Two properties of this flow are worth knowing even if you never build against it:
POST /merchant-accounts/session/linkalways answers202, whether or not the address is a registered, active user. A mail is sent only if it is. Nothing in the response reveals who has access.- The code is single-use and short-lived. Exchanging it a second time fails with
409 authorization-code-already-redeemed; an expired or mistyped one is404 authorization-code-not-found. In either case the remedy is the same: request a fresh link. - Failures are rare and retryable. If the link could not be issued or sent, the request fails with
502andtype: urn:qup:error:merchant-signin-link-failed; try again shortly. Acodethat is not exactly 43 characters is a400— a mangled link, not an expired one.
The token a portal user receives carries the same debtor_codes and scope as a credential's, resolved from the merchants the invitation named. Inviting the same person again adds debtor codes to their access rather than creating a second user.
If you find yourself scripting the sign-in link, stop and ask for an API credential instead. The link is for people; the secret is for systems.
Updated about 22 hours ago
