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.

PrincipalRepresentsObtained bySigns in with
API credentialA system of yours — bookkeeping, BI, a scheduled jobIssued by QueueUp, on requestclient_id + client_secret, exchanged at POST /merchant-accounts/credentials/token
Portal userA person on your sideInvited by QueueUp, by e-mailA 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_idPublic identifier, prefixed s2s_. Safe to log.
client_secretShown 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_type is optional, but if present it must be client_credentials. Anything else is a 400 unsupported_grant_type.
  • scope is 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 token

Merchant tokens are short-lived — read expires_in rather 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:

ClaimMeaning
debtor_codesThe merchants this principal may read. The report endpoint checks every request against it.
scopemerchant/reports.read — present whenever the principal covers at least one debtor code
user_idThe 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.

StatuserrorWhat happenedWhat to do
400invalid_requestMalformed body, or credentials sent both in Basic auth and in the bodyFix the request
400unsupported_grant_typegrant_type present and not client_credentialsSend client_credentials or omit it
401invalid_clientUnknown client_id, wrong secret, or a credential that has been disabledCheck 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:

  1. Ask QueueUp to issue a new credential for the same debtor codes.
  2. Deploy the new secret and confirm it exchanges.
  3. 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/link always answers 202, 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 is 404 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 502 and type: urn:qup:error:merchant-signin-link-failed; try again shortly. A code that is not exactly 43 characters is a 400 — 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.


Did this page help you?