Skip to main content
Expys uses a two-token model. Getting this right is the single most confusable part of the integration, so read this page before writing any code.

The two credentials

CredentialLivesUsed for
Org-API-KeyYour backend only (a secret)POST /v1/auth/exchange and all server-mode calls
Member tokenThe app / SDKEvery member-mode data call (offers, redemptions, eligibility, wallet, concierge)
The Org-API-Key is a secret with full org authority. Never ship it in an app, a mobile binary, an environment variable shipped to clients, or any client-side code. A member token that leaks only exposes one member for a few minutes; an Org-API-Key that leaks exposes everything.

How the flow works

Your backend holds the Org-API-Key and exchanges it for a short-lived member token scoped to one of your users. The app uses that member token as a Bearer credential for data calls, and asks your backend for a fresh one when it nears expiry.

Step 1: Exchange (server-side)

Your backend calls exchangeToken with the Org-API-Key. The request identifies the member by your own stable id, and may set profile fields used elsewhere (display name, email, and the member’s tier).
externalUserID
string
required
Your stable identifier for the member. Scopes the minted token to this user.
displayName
string
Optional display name stored on the member profile.
email
string
Optional email stored on the member profile.
tier
string
Optional tier for the member; flows into eligibility.
curl -X POST https://api.expys.com/v1/auth/exchange \
  -H "Authorization: Bearer YOUR_ORG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "externalUserID": "user_123", "tier": "gold" }'
The response is a TokenGrant:
accessToken
string
required
The short-lived member token. Hand this to the app.
expiresAt
string
required
ISO-8601 expiry. Pass it to the SDK as tokenExpiresAt(Ms) so it can refresh proactively.

Step 2: Use and refresh (in the app)

Initialize the SDK with the member token, its expiry, and a refreshToken hook that calls back to your backend (which holds the Org-API-Key) to mint a new token. The hook returns the new token and expiry.
import { initialize, type TokenRefreshResult } from "@expys/sdk";

const token = process.env.EXPYS_MEMBER_TOKEN;
if (!token) {
  throw new Error(
    "Set EXPYS_MEMBER_TOKEN (a member token from your backend's /v1/auth/exchange)",
  );
}

const refreshUrl = process.env.EXPYS_REFRESH_URL ?? "/api/expys/refresh";

// Calls your backend, which re-exchanges the Org-API-Key and returns a fresh
// token. Returning `expiresAt` re-arms proactive refresh for the next call.
async function refreshToken(): Promise<TokenRefreshResult> {
  const res = await fetch(refreshUrl, { method: "POST" });
  if (!res.ok) {
    // A thrown refresh propagates to your call as an ExpysError and is NOT retried.
    throw new Error(`refresh failed: ${res.status}`);
  }
  return res.json() as Promise<TokenRefreshResult>;
}

const expys = initialize({
  baseUrl: process.env.EXPYS_BASE_URL,
  environment: "live",
  // Refresh ~60s before expiry. Setting tokenExpiresAt enables proactive refresh;
  // omit it to rely solely on reactive (401) refresh.
  refreshSkewMs: 60_000,
  refreshToken,
  token,
  tokenExpiresAt: Date.now() + 5 * 60_000,
});

async function main(): Promise<void> {
  // If the token is within the skew window, the SDK refreshes before this call;
  // on a 401 it refreshes once and retries with the new token.
  const wallet = await expys.wallet();
  console.log(`wallet balance: ${wallet.balance}`);
}

The refresh contract

The SDKs implement refresh identically:
1

Proactive

Before a request, if the token expires within refreshSkew (default 30s), the SDK calls refreshToken first and uses the new token.
2

Reactive (once)

If a request still returns 401, the SDK calls refreshToken once and retries the request a single time with the new token.
3

Failure propagates

If refreshToken throws, the SDK does not retry it - the error propagates so your app can send the user back through your own auth. A failed refresh is never retried in a loop.
Provide tokenExpiresAt(Ms) whenever you can. Without it the SDK cannot refresh proactively and falls back to reactive (401-triggered) refresh only, which adds one round-trip on the first expired call.

Security checklist

Org-API-Key is only ever read on your backend.
The app receives only member tokens, with their expiry.
refreshToken calls your backend, not the Expys exchange endpoint directly.
Member tokens are short-lived; do not persist them beyond their expiry.