White-label single sign-on guide
Who this guide is for: publishers on the white-label integration path, where SpendStream hosts a branded rewards storefront for your customers. If you are building your own rewards UI and calling SpendStream endpoints directly, you are on the API-only path; see the API Reference instead. Most of that reference does not apply to you.
1. What you’re building
SpendStream hosts a fully branded rewards storefront at your own subdomain, for example:
https://yourbrand.stg.spendstream.co
Your customers reach it from your platform without logging in again. That single-sign-on handover is the only thing you need to build. SpendStream handles the storefront, brand catalogue, gift card fulfilment, transactions, payments, and infrastructure.
Responsibility | Owner |
Brand assets, colours, logo | You: supplied once during onboarding |
Reward structure and configuration | You: agreed once during onboarding |
Storefront, catalogue, checkout, payments | SpendStream |
Registering your customers with SpendStream | You: via the API (§4) |
Signing customers into the storefront | You: via the SSO redirect (§5) |
Everything in the first two rows is a one-off setup handled with your account manager. This guide covers the last two rows, which are the code you write.
2. The flow at a glance
sequenceDiagram
participant C as Your customer
participant Y as Your backend
participant A as SpendStream API
participant S as Your storefront
Note over Y,A: Once every 10 hours
Y->>A: POST /getToken (HMAC signed)
A-->>Y: Bearer token (10h)
C->>Y: Clicks "Rewards"
alt First visit
Y->>A: POST /addNewUser (UID)
A-->>Y: user record incl. token
else Returning visitor
Y->>A: GET /getUsersList?UID=…
A-->>Y: user record incl. token
end
Y-->>C: 302 → /login/{token}
C->>S: GET /login/{token}
S-->>C: Signed in, session started
Three calls, one redirect. That’s the whole integration.
3. Authentication
All API calls need a bearer token. This authenticates your organisation, not an individual customer; you fetch one, cache it, and reuse it for every customer.
Get a bearer token
- Take your
API_KEYand the current UTC timestamp formattedYYYY-MM-DD HH:MM:SS. - Concatenate them:
API_KEY + timestamp(no separator). - Sign with HMAC-SHA256 using your
SECRET_KEY.
curl -X POST https://staging-api.spendstream.io/v2/getToken \
-H 'Content-Type: application/json' \
-d '{
"api_key": "YOUR_API_KEY",
"time_stamp": "2026-08-20 14:30:00",
"code": "hmac_sha256_signature"
}'
import crypto from 'crypto';
const timestamp = new Date().toISOString().slice(0, 19).replace('T', ' ');
const code = crypto
.createHmac('sha256', SECRET_KEY)
.update(API_KEY + timestamp)
.digest('hex');
const res = await fetch('https://staging-api.spendstream.io/v2/getToken', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ api_key: API_KEY, time_stamp: timestamp, code }),
});
const { token } = await res.json();
import hmac, hashlib, requests
from datetime import datetime, timezone
timestamp = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
code = hmac.new(SECRET_KEY.encode(), (API_KEY + timestamp).encode(), hashlib.sha256).hexdigest()
res = requests.post(
'https://staging-api.spendstream.io/v2/getToken',
json={'api_key': API_KEY, 'time_stamp': timestamp, 'code': code},
)
token = res.json()['token']
The bearer token is valid for 10 hours. Cache it and reuse it across all customers.
⚠️ Do not request a bearer token per customer click. POST /getToken is rate-limited to five requests per minute. Fetch one, cache it, refresh shortly before expiry or when a call returns 401.
Every subsequent request carries it:
Authorization: Bearer YOUR_TOKEN
⚠️ Set an explicit User-Agent on every request. Requests are screened at the CDN before they reach the API, and some HTTP clients’ default agents are rejected with a 403 and a plain-text error code: 1010 body rather than a JSON error. Any identifying value works For example, User-Agent: YourApp/1.0.
Environments
Environment | Base URL |
Staging |
|
Production |
|
4. Getting a customer’s SSO token
Each of your customers has an SSO token, which is a per-customer value that signs them into the storefront. It is separate from the bearer token above.
You obtain it from one of two endpoints, depending on whether you’ve registered the customer before.
4.1 First visit: register the customer
curl -X POST https://staging-api.spendstream.io/v2/addNewUser \
-H 'Authorization: Bearer YOUR_BEARER_TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"UID": "your-customer-id-12345",
"name": "Jane Doe",
"email": "jane.doe@example.com",
"phone": "+447700900123"
}'
UID is your permanent identifier for that customer. It must be unique within your account and must never change for that person; it is the key everything else hangs off. Only UID is required; name, email, and phone are optional but recommended.
Success response:
{
"users": {
"message": "User successfully registered",
"response": 200,
"user": {
"UID": "your-customer-id-12345",
"name": "Jane Doe",
"phone": "+447700900123",
"email": "jane.doe@example.com",
"status": "1",
"currency": "GBP",
"confirmed-amount": 0,
"withdrawn-amount": 0,
"total-amount": 0,
"total-commission": 0,
"token": "a1b2c3d4e5f60718293a4b5c6d7e8f90"
}
}
}
user.token is the SSO token. Use it in §5. The balance fields are zero for a new user and are covered by the reporting endpoints, not this guide.
4.2 Returning visit: look the customer up
Do not call addNewUser again for someone you’ve already registered. Fetch their existing record instead:
curl -X GET 'https://staging-api.spendstream.io/v2/getUsersList?UID=your-customer-id-12345' \
-H 'Authorization: Bearer YOUR_BEARER_TOKEN'
Response:
{
"users": {
"total-users": 1,
"partner-name": "YourBrand",
"users-list": [
{
"UID": "your-customer-id-12345",
"name": "Jane Doe",
"email": "jane.doe@example.com",
"status": "1",
"currency": "GBP",
"confirmed-amount": "0.00",
"withdrawn-amount": "0.00",
"total-amount": "0.00",
"total-commission": "0.00",
"token": "a1b2c3d4e5f60718293a4b5c6d7e8f90",
"token-expiry": 4
}
],
"response": 200
}
}
Read users-list[0].token.
💡 total-users tells you whether the customer exists. users-list is an array even when you filter by a single UID, so read index 0.
4.3 Deciding which call to make
If you track registration state on your side, use it: register once, then always call getUsersList afterwards.
If you’d rather not store that flag, call getUsersList first and treat an empty result as “not registered yet”:
async function getSsoToken(uid) {
const lookup = await api(`/getUsersList?UID=${encodeURIComponent(uid)}`);
if (lookup.users['total-users'] > 0) {
return lookup.users['users-list'][0].token;
}
const created = await api('/addNewUser', { method: 'POST', body: { UID: uid } });
return created.users.user.token;
}
⚠️ Two behaviours that will catch you out.
A lookup for an unknown customer is not an error. getUsersList returns HTTP 200 with "total-users": 0 and an empty users-list. Branch on the count, not on a status code or a thrown exception.
A duplicate registration is not an HTTP 400. Calling addNewUser with a UID that already exists returns HTTP 200, with the failure signalled inside the body:
{ "users": { "message": "User already exists with UID", "response": 400 } }
Note there is no token in that response, so you cannot use it as a shortcut to fetch an existing customer’s token. If you hit it, fall back to getUsersList. Check users.response, not the HTTP status.
5. Signing the customer in
Redirect the customer’s browser to your storefront’s /login/ path with their SSO token as the final path segment:
https://YOUR_SUBDOMAIN.spendstream.co/login/SSO_TOKEN
For example:
https://yourbrand.stg.spendstream.co/login/a1b2c3d4e5f60718293a4b5c6d7e8f90
The storefront validates the token, starts a session, and lands the customer in the rewards experience with no second login.
💡 The token is a path segment, not a query parameter. /login?token=… will not work.
Controlling where they land
Append redirecturl with an absolute URL to send the customer to a specific page after sign-in:
https://yourbrand.stg.spendstream.co/login/a1b2c3d4e5f60718293a4b5c6d7e8f90?redirecturl=https://yourbrand.stg.spendstream.co/all-brands
Omit it and customers go to the brands listing on return visits, or the storefront homepage on their first ever login.
Implementation sketch
app.get('/rewards', async (req, res) => {
const token = await getSsoToken(req.user.id); // §4.3
res.redirect(`https://yourbrand.stg.spendstream.co/login/${token}`);
});
That is the complete integration.
6. Token lifetimes
Token | Scope | Lifetime | Where it comes from |
Bearer token | Your organisation | 10 hours |
|
SSO token | One customer | See below |
|
The bearer token expires after 10 hours and must be refreshed.
The SSO token is stored against the customer record. getUsersList returns it alongside a token-expiry field with a value of 4, indicating a four-hour intended lifetime. Build as though that window applies, and fetch a fresh token each time a customer clicks through, rather than storing one and reusing it days later.
💡 Fetch the SSO token fresh on each visit rather than caching it long-term in your own database. It is cheap (one API call) and it keeps your integration correct if the customer’s token is ever reissued. Treat the SSO URL itself as a credential: it signs someone in, so don’t log it, email it, or expose it in analytics or client-side code.
7. Testing the storefront
You do not implement payments, because SpendStream handles checkout on your storefront. You will want to click through a full purchase yourself when testing the SSO handover end to end.
On staging, use these test cards:
Scheme | Card number |
Visa |
|
Mastercard |
|
- Expiry date: any valid future date
- Security code (CVV):
123 - American Express is not supported. Apple Pay and Google Pay are enabled.
Nothing on staging costs real money. The card payment runs against the payment provider’s test site and the voucher is issued from the gift card supplier’s sandbox, so you will see a sandbox. host in the resulting gift card URL. Sandbox voucher codes are simulated and not redeemable, so test as often as you like.
Two things that catch people out:
- An amount of
700.00is always declined by the test bank, and600.10returns a bank system error, regardless of the card used. If a test fails for no obvious reason, check the amount before the card. - Avoid refreshing the payment success page. A repeat visit to an already paid order is rejected, but a narrow timing overlap is still being hardened, so do not rely on it.
For decline handling, 3-D Secure scenarios, and the full card list, see Testing on staging.
8. Troubleshooting
What you see | Likely cause |
Redirected to the storefront homepage, logged out | Token not recognised. The storefront redirects silently rather than showing an error: confirm you’re sending the exact |
| Bearer token missing, malformed, or expired (10 hours). Fetch a new one |
| HMAC inputs wrong. Check the concatenation order ( |
| More than five token requests in a minute. Cache the bearer token instead of fetching per request |
| Blocked at the CDN before reaching the API, based on your HTTP client’s |
| Expected for a returning customer: switch to |
| Customer not registered yet: call |
Blank page at | No token in the path |
9. Go-live checklist
- [ ] Brand assets and reward configuration signed off with your account manager
- [ ] Bearer token cached server-side and refreshed before its 10-hour expiry, not fetched per request
- [ ]
UIDis a stable, permanent identifier for each customer - [ ] First-visit and returning-visit paths both tested against staging
- [ ] Duplicate
addNewUserhandled by checkingusers.response, not the HTTP status - [ ] Empty
getUsersListresult handled by checkingtotal-users, not an error - [ ] SSO URLs never logged, emailed, or exposed client-side
- [ ] Staging URLs and credentials swapped for production equivalents
- [ ] Production API key and secret issued by SpendStream
On this page
- White-label single sign-on guide