> ## Documentation Index
> Fetch the complete documentation index at: https://docs.expys.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Authentication and token refresh

> The two-token model: your backend mints short-lived member tokens with a secret Org-API-Key, and the app refreshes them.

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

| Credential       | Lives                        | Used for                                                                          |
| ---------------- | ---------------------------- | --------------------------------------------------------------------------------- |
| **Org-API-Key**  | Your backend only (a secret) | `POST /v1/auth/exchange` and all [server-mode](/guides/server-mode) calls         |
| **Member token** | The app / SDK                | Every member-mode data call (offers, redemptions, eligibility, wallet, concierge) |

<Warning>
  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.
</Warning>

## 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.

```mermaid theme={null}
sequenceDiagram
    participant App as Your app (SDK)
    participant Backend as Your backend
    participant Expys as Expys API

    App->>Backend: Request a session (your own auth)
    Backend->>Expys: POST /v1/auth/exchange (Org-API-Key)
    Expys-->>Backend: TokenGrant { accessToken, expiresAt }
    Backend-->>App: member token + expiresAt
    App->>Expys: GET /v1/offers (Bearer member token)
    Expys-->>App: 200 OfferList
    Note over App,Expys: Token nears expiry (within refreshSkew)
    App->>Backend: refreshToken() hook
    Backend->>Expys: POST /v1/auth/exchange (Org-API-Key)
    Expys-->>Backend: new TokenGrant
    Backend-->>App: new member token
```

## 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](/guides/members)).

<ParamField path="externalUserID" type="string" required>
  Your stable identifier for the member. Scopes the minted token to this user.
</ParamField>

<ParamField path="displayName" type="string">
  Optional display name stored on the member profile.
</ParamField>

<ParamField path="email" type="string">
  Optional email stored on the member profile.
</ParamField>

<ParamField path="tier" type="string">
  Optional tier for the member; flows into [eligibility](/guides/eligibility).
</ParamField>

```bash theme={null}
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`:

<ResponseField name="accessToken" type="string" required>
  The short-lived member token. Hand this to the app.
</ResponseField>

<ResponseField name="expiresAt" type="string" required>
  ISO-8601 expiry. Pass it to the SDK as `tokenExpiresAt(Ms)` so it can refresh
  proactively.
</ResponseField>

## 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.

<CodeGroup>
  ```typescript TypeScript theme={null}
  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}`);
  }
  ```

  ```swift Swift theme={null}
  import ExpysSDK
  import Foundation

  #if canImport(FoundationNetworking)
    import FoundationNetworking
  #endif

  /// Shape your backend's refresh endpoint returns. Decoded here (not in the SDK)
  /// so the SDK stays transport-agnostic about your token plumbing.
  private struct RefreshResponse: Decodable {
    let accessToken: String
    let expiresInSeconds: Double?
  }

  @main
  struct TokenRefreshExample {
    static func main() async throws {
      let environment = ProcessInfo.processInfo.environment
      guard let token = environment["EXPYS_MEMBER_TOKEN"] else {
        fatalError("Set EXPYS_MEMBER_TOKEN (a member token from your backend's /v1/auth/exchange)")
      }
      let baseURL =
        environment["EXPYS_BASE_URL"].flatMap(URL.init(string:))
        ?? ExpysConfiguration.defaultBaseURL
      let refreshURL =
        environment["EXPYS_REFRESH_URL"].flatMap(URL.init(string:))
        ?? URL(string: "https://example.com/api/expys/refresh")!

      let client = ExpysClient(
        configuration: ExpysConfiguration(
          token: token,
          environment: .live,
          baseURL: baseURL,
          // Setting tokenExpiresAt enables proactive refresh; omit it to rely solely
          // on reactive (401) refresh.
          tokenExpiresAt: Date().addingTimeInterval(5 * 60),
          // Calls your backend, which re-exchanges the Org-API-Key. A thrown refresh
          // propagates to your call as an ExpysError and is NOT retried.
          refreshToken: {
            var request = URLRequest(url: refreshURL)
            request.httpMethod = "POST"
            let (data, response) = try await URLSession.shared.data(for: request)
            guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode)
            else {
              throw ExpysError.network("refresh failed")
            }
            let body = try JSONDecoder().decode(RefreshResponse.self, from: data)
            // Returning expiresAt re-arms proactive refresh for the next call.
            return TokenRefresh(
              accessToken: body.accessToken,
              expiresAt: body.expiresInSeconds.map { Date().addingTimeInterval($0) }
            )
          },
          // Refresh ~60s before expiry.
          refreshSkew: 60
        )
      )

      // 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.
      let wallet = try await client.wallet()
      print("wallet balance: \(wallet.balance)")
    }
  }
  ```

  ```kotlin Kotlin theme={null}
  package com.expys.sdk.examples.tokenrefresh

  import com.expys.sdk.ExpysClient
  import com.expys.sdk.ExpysConfiguration
  import com.expys.sdk.TokenRefresh
  import kotlinx.coroutines.runBlocking

  fun main() = runBlocking {
    val token = System.getenv("EXPYS_MEMBER_TOKEN")
      ?: error("Set EXPYS_MEMBER_TOKEN (a member token from your backend's /v1/auth/exchange)")

    val client = ExpysClient.create(
      ExpysConfiguration(
        token = token,
        tokenExpiresAtMs = System.currentTimeMillis() + 5 * 60_000,
        refreshSkewMs = 60_000,
        refreshToken = {
          // Call YOUR backend, which re-exchanges the Org-API-Key, and return a fresh
          // token. TokenRefresh is constructed, not decoded, so your payload shape is
          // your concern. This stub just reuses the env token for illustration.
          TokenRefresh(accessToken = token, expiresAtMs = System.currentTimeMillis() + 5 * 60_000)
        },
        baseUrl = System.getenv("EXPYS_BASE_URL") ?: ExpysConfiguration.DEFAULT_BASE_URL,
      ),
    )

    val wallet = client.wallet()
    println("balance: ${wallet.balance}")
  }
  ```
</CodeGroup>

### The refresh contract

The SDKs implement refresh identically:

<Steps>
  <Step title="Proactive">
    Before a request, if the token expires within `refreshSkew` (default 30s),
    the SDK calls `refreshToken` first and uses the new token.
  </Step>

  <Step title="Reactive (once)">
    If a request still returns `401`, the SDK calls `refreshToken` once and
    retries the request a single time with the new token.
  </Step>

  <Step title="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.
  </Step>
</Steps>

<Note>
  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.
</Note>

## Security checklist

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