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

# Points and wallet

> The end-to-end points loop: mint server-side with the Org-API-Key, spend by redeeming offers, read the balance, and audit every change in the wallet ledger.

Points are the spendable currency of a member's [wallet](/guides/wallet). They
are **minted** by your backend, **spent** when a member redeems an offer, and
**refunded** when a redemption is canceled. Every change is recorded as a
transaction you can read back as a ledger.

<Warning>
  This page describes **member-wallet settlement**, the default. If your
  organization is on **org-pool settlement**, there are no per-VIP wallets at all:
  you never call `POST /v1/wallet/credit`, and a redemption debits your
  organization's pool directly. Skip to
  [Billing and settlement](/guides/billing) instead. Check which mode you are on
  with `GET /v1/balance`.
</Warning>

```mermaid theme={null}
flowchart LR
    Mint["Mint (server-mode)<br/>POST /v1/wallet/credit"] -->|+amount| Balance["Wallet balance"]
    Balance -->|-pointsPrice| Spend["Spend (member-mode)<br/>redeem an offer"]
    Spend -->|cancel refunds +pointsPrice| Balance
```

Two modes touch the wallet, and they use different credentials:

| Step         | Mode        | Credential                 | Effect on balance  |
| ------------ | ----------- | -------------------------- | ------------------ |
| Mint         | Server-mode | Org-API-Key (backend only) | Increases (credit) |
| Spend        | Member-mode | Member token (app)         | Decreases (debit)  |
| Refund       | Member-mode | Member token (app)         | Increases (credit) |
| Read balance | Member-mode | Member token (app)         | None               |
| Read ledger  | Member-mode | Member token (app)         | None               |

<Note>
  Member-mode calls authenticate with the short-lived **member token** your
  backend mints (see [Authentication](/authentication)), and the
  [environment](/environments) - sandbox or live - is selected by the key the
  token was minted from. Server-mode calls authenticate with the Org-API-Key
  directly.
</Note>

## Mint points (server-side)

Minting credits points into a member's wallet. Your backend calls
`POST /v1/wallet/credit` with the **Org-API-Key**. `amount` is an integer number
of points, and an optional `reason` is stored on the resulting credit
transaction.

<Warning>
  Minting is **server-side only**. It requires the Org-API-Key and must run on
  your backend - never in an app or mobile binary. A member token calling
  `POST /v1/wallet/credit` is rejected with `403 FORBIDDEN`. The Org-API-Key has
  full org authority; treat it as a secret.
</Warning>

<ParamField path="externalUserID" type="string" required>
  Your stable identifier for the member to credit.
</ParamField>

<ParamField path="amount" type="integer" required>
  The number of points to mint. A positive integer.
</ParamField>

<ParamField path="reason" type="string">
  Optional free text stored on the credit transaction (for example
  `"welcome bonus"`).
</ParamField>

Send an `Idempotency-Key` header so a retried mint replays the original result
rather than crediting twice. See
[Retries and idempotency](/guides/retries-and-idempotency).

```bash theme={null}
curl -X POST https://api.expys.com/v1/wallet/credit \
  -H "Authorization: Bearer YOUR_ORG_API_KEY" \
  -H "Idempotency-Key: 0f3a9c2e-4b1d-4e6a-9c7b-1f2e3d4c5b6a" \
  -H "Content-Type: application/json" \
  -d '{
    "externalUserID": "user_123",
    "amount": 100,
    "reason": "welcome bonus"
  }'
```

The response is a `CreditWalletResponse` with the member's new balance:

<ResponseField name="balance" type="number" required>
  The member's wallet balance after the credit.
</ResponseField>

<ResponseField name="currency" type="object" required>
  The wallet currency, with `name` and `symbol`.
</ResponseField>

In the SDKs, minting is `creditPoints(...)`, one of the server-mode methods that
run with the Org-API-Key. The full server-mode flow - exchanging a member token,
upserting a member, and crediting points - looks like this:

<CodeGroup>
  ```typescript TypeScript theme={null}
  import { initialize } from "@expys/sdk";

  const orgApiKey = process.env.EXPYS_ORG_API_KEY;
  if (!orgApiKey) {
    throw new Error(
      "Set EXPYS_ORG_API_KEY (your secret Org-API-Key, e.g. expys_live_...). " +
        "Run this on a backend only, never in a client app.",
    );
  }

  // Construct the client with the machine credential as the token. Server-mode
  // methods are guarded against member tokens client-side.
  const expys = initialize({
    baseUrl: process.env.EXPYS_BASE_URL,
    environment: "sandbox",
    token: orgApiKey,
  });

  const externalUserID = process.env.EXPYS_EXTERNAL_USER_ID ?? "user_42";

  async function main(): Promise<void> {
    // Mint a short-lived member token for your app to use (return this to the app,
    // never the Org-API-Key). Idempotent POST: a retry replays rather than re-mints.
    const grant = await expys.exchangeToken({ externalUserID });
    console.log(`minted member token expiring at ${grant.expiresAt}`);

    // Upsert the member's profile. PUT is idempotent by HTTP semantics (no key).
    const member = await expys.setMember(externalUserID, {
      displayName: "Ada Lovelace",
      tier: "gold",
    });
    console.log(`member ${member.externalUserID} is now tier=${member.tier}`);

    // Credit points to the member's wallet. Idempotent POST sends an Idempotency-Key.
    const credited = await expys.creditPoints({
      amount: 100,
      externalUserID,
      reason: "welcome bonus",
    });
    console.log(`new balance: ${credited.balance} ${credited.currency.symbol}`);

    // Register a webhook. The signingSecret is shown ONLY on creation - store it now.
    const webhook = await expys.createWebhook({
      events: ["redemption.created"],
      url: "https://example.com/expys/webhooks",
    });
    console.log(`webhook ${webhook.id} secret: ${webhook.signingSecret}`);

    // Org-wide analytics rollups.
    const summary = await expys.analyticsSummary();
    console.log(
      `members: ${summary.memberCount}, minted: ${summary.pointsMinted}`,
    );
  }
  ```

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

  @main
  struct ServerModeExample {
    static func main() async throws {
      let environment = ProcessInfo.processInfo.environment
      guard let orgApiKey = environment["EXPYS_ORG_API_KEY"] else {
        fatalError(
          "Set EXPYS_ORG_API_KEY (your secret Org-API-Key, e.g. expys_live_...). "
            + "Run this on a backend only, never in a client app.")
      }
      let baseURL =
        environment["EXPYS_BASE_URL"].flatMap(URL.init(string:))
        ?? ExpysConfiguration.defaultBaseURL
      let externalUserID = environment["EXPYS_EXTERNAL_USER_ID"] ?? "user_42"

      // Configure the client with the machine credential as the token. Server-mode
      // methods are guarded against member tokens client-side.
      let client = ExpysClient(
        configuration: ExpysConfiguration(token: orgApiKey, environment: .sandbox, baseURL: baseURL)
      )

      // Mint a short-lived member token for your app to use (return this to the app,
      // never the Org-API-Key). Idempotent POST: a retry replays rather than re-mints.
      let grant = try await client.exchangeToken(TokenExchangeRequest(externalUserID: externalUserID))
      print("minted member token expiring at \(grant.expiresAt)")

      // Upsert the member's profile. PUT is idempotent by HTTP semantics (no key).
      let member = try await client.setMember(
        externalUserID: externalUserID,
        SetMemberRequest(displayName: "Ada Lovelace", tier: "gold"))
      print("member \(member.externalUserID) is now tier=\(member.tier)")

      // Credit points to the member's wallet. Idempotent POST sends an Idempotency-Key.
      let credited = try await client.creditPoints(
        CreditWalletRequest(amount: 100, externalUserID: externalUserID, reason: "welcome bonus"))
      print("new balance: \(credited.balance) \(credited.currency.symbol)")

      // Register a webhook. The signingSecret is shown ONLY on creation - store it now.
      let webhook = try await client.createWebhook(
        CreateWebhookRequest(
          events: ["redemption.created"], url: "https://example.com/expys/webhooks"))
      print("webhook \(webhook.id) secret: \(webhook.signingSecret)")

      // Org-wide analytics rollups.
      let summary = try await client.analyticsSummary()
      print("members: \(summary.memberCount), minted: \(summary.pointsMinted)")
    }
  }
  ```

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

  import com.expys.sdk.ExpysClient
  import com.expys.sdk.ExpysConfiguration
  import com.expys.sdk.ExpysEnvironment
  import com.expys.sdk.models.CreateWebhookRequest
  import com.expys.sdk.models.CreditWalletRequest
  import com.expys.sdk.models.SetMemberRequest
  import com.expys.sdk.models.TokenExchangeRequest
  import kotlinx.coroutines.runBlocking
  import java.net.URI

  fun main() = runBlocking {
    val orgApiKey = System.getenv("EXPYS_ORG_API_KEY")
      ?: error(
        "Set EXPYS_ORG_API_KEY (your secret Org-API-Key, e.g. expys_live_...). " +
          "Run this on a backend only, never in a client app.",
      )
    val externalUserID = System.getenv("EXPYS_EXTERNAL_USER_ID") ?: "user_42"

    // Configure the client with the machine credential as the token. Server-mode
    // methods are guarded against member tokens client-side.
    val client = ExpysClient.create(
      ExpysConfiguration(
        token = orgApiKey,
        environment = ExpysEnvironment.SANDBOX,
        baseUrl = System.getenv("EXPYS_BASE_URL") ?: ExpysConfiguration.DEFAULT_BASE_URL,
      ),
    )

    // Mint a short-lived member token for your app to use (return this to the app,
    // never the Org-API-Key). Idempotent POST: a retry replays rather than re-mints.
    val grant = client.exchangeToken(TokenExchangeRequest(externalUserID = externalUserID))
    println("minted member token expiring at ${grant.expiresAt}")

    // Upsert the member's profile. PUT is idempotent by HTTP semantics (no key).
    val member = client.setMember(externalUserID, SetMemberRequest(displayName = "Ada Lovelace", tier = "gold"))
    println("member ${member.externalUserID} is now tier=${member.tier}")

    // Credit points to the member's wallet. Idempotent POST sends an Idempotency-Key.
    val credited = client.creditPoints(
      CreditWalletRequest(amount = 100, externalUserID = externalUserID, reason = "welcome bonus"),
    )
    println("new balance: ${credited.balance} ${credited.currency.symbol}")

    // Register a webhook. The signingSecret is shown ONLY on creation - store it now.
    val webhook = client.createWebhook(
      CreateWebhookRequest(events = listOf("redemption.created"), url = URI("https://example.com/expys/webhooks")),
    )
    println("webhook ${webhook.id} secret: ${webhook.signingSecret}")

    // Org-wide analytics rollups.
    val summary = client.analyticsSummary()
    println("members: ${summary.memberCount}, minted: ${summary.pointsMinted}")
  }
  ```
</CodeGroup>

## Spend points (member-side)

Members spend points by redeeming an offer. Each [offer](/guides/offers) carries
a `pointsPrice` - an integer points cost (or `null` for offers that are not
points-priced). Redeeming debits `pointsPrice` from the wallet; the redemption
records the spend.

| Field         | Where | Meaning                                                           |
| ------------- | ----- | ----------------------------------------------------------------- |
| `pointsPrice` | Offer | Integer points to redeem the offer (`null` if not points-priced). |

If the member's balance is below `pointsPrice`, the redemption fails with
`INSUFFICIENT_POINTS` (`422`). **Canceling** a redemption refunds its
`pointsPrice` back to the wallet as a credit. The mechanics of submitting,
tracking, and canceling a redemption live in
[Redemptions](/guides/redemptions).

<Tip>
  Read `pointsPrice` against the wallet `balance` before offering a redeem
  action, so the member never hits an `INSUFFICIENT_POINTS` error mid-flow.
</Tip>

## Read the balance (member-side)

`GET /v1/wallet` returns the current `Wallet`. It is a member-mode call - use the
member token.

<ResponseField name="balance" type="number" required>
  The current spendable balance.
</ResponseField>

<ResponseField name="amountReceived" type="number" required>
  Lifetime total credited to this member.
</ResponseField>

<ResponseField name="amountSpent" type="number" required>
  Lifetime total debited from this member.
</ResponseField>

<ResponseField name="currency" type="object" required>
  The wallet currency, with `name` and `symbol`.
</ResponseField>

```bash theme={null}
curl https://api.expys.com/v1/wallet \
  -H "Authorization: Bearer YOUR_MEMBER_TOKEN"
```

The same `Wallet` is also embedded in the
[eligibility](/guides/eligibility) response, so a single eligibility call gives
you tier and balance together:

<CodeGroup>
  ```typescript TypeScript theme={null}
  import { initialize } 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 expys = initialize({
    baseUrl: process.env.EXPYS_BASE_URL,
    environment: "sandbox",
    token,
  });

  async function main(): Promise<void> {
    // externalUserID names the member when a machine token calls on their behalf.
    const eligibility = await expys.eligibility({
      externalUserID: process.env.EXPYS_EXTERNAL_USER_ID,
    });
    console.log(`tier: ${eligibility.tier}`);
    console.log(`wallet (from eligibility): ${eligibility.wallet.balance}`);

    const wallet = await expys.wallet();
    console.log(
      `wallet: balance=${wallet.balance} received=${wallet.amountReceived} ` +
        `spent=${wallet.amountSpent} ${wallet.currency.symbol} (${wallet.currency.name})`,
    );
  }
  ```

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

  @main
  struct EligibilityWalletExample {
    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 client = ExpysClient(
        configuration: ExpysConfiguration(token: token, environment: .sandbox, baseURL: baseURL)
      )

      // externalUserID names the member when a machine token calls on their behalf.
      let eligibility = try await client.eligibility(
        externalUserID: environment["EXPYS_EXTERNAL_USER_ID"]
      )
      print("tier: \(eligibility.tier)")
      print("wallet (from eligibility): \(eligibility.wallet.balance)")

      let wallet = try await client.wallet()
      print(
        "wallet: balance=\(wallet.balance) received=\(wallet.amountReceived) "
          + "spent=\(wallet.amountSpent) \(wallet.currency.symbol) (\(wallet.currency.name))"
      )
    }
  }
  ```

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

  import com.expys.sdk.ExpysClient
  import com.expys.sdk.ExpysConfiguration
  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,
        baseUrl = System.getenv("EXPYS_BASE_URL") ?: ExpysConfiguration.DEFAULT_BASE_URL,
      ),
    )

    // externalUserID names the member when a machine token calls on their behalf.
    val eligibility = client.eligibility(externalUserID = System.getenv("EXPYS_EXTERNAL_USER_ID"))
    println("tier: ${eligibility.tier}")

    val wallet = client.wallet()
    println("balance: ${wallet.balance} ${wallet.currency.symbol}")
    println("received: ${wallet.amountReceived}, spent: ${wallet.amountSpent}")
  }
  ```
</CodeGroup>

## Read the ledger (member-side)

`GET /v1/wallet/transactions` returns every wallet change as a list of
`Transaction` records, newest first, with cursor pagination. In the SDKs this is
`walletTransactions(...)`.

<ParamField query="limit" type="integer">
  Page size. Defaults to the server's default if omitted.
</ParamField>

<ParamField query="cursor" type="string">
  The `nextCursor` from the previous page. Omit for the first page.
</ParamField>

<ParamField query="externalUserID" type="string">
  Names the member when a machine token calls on their behalf.
</ParamField>

```bash theme={null}
curl "https://api.expys.com/v1/wallet/transactions?limit=50" \
  -H "Authorization: Bearer YOUR_MEMBER_TOKEN"
```

The response is a `ListTransactionsResponse`:

<ResponseField name="transactions" type="Transaction[]" required>
  The page of transactions, newest first.
</ResponseField>

<ResponseField name="nextCursor" type="string | null" required>
  Pass this back as `cursor` to fetch the next page. `null` marks the end of the
  list.
</ResponseField>

### The Transaction schema

| Field          | Type           | Meaning                                                                                                             |
| -------------- | -------------- | ------------------------------------------------------------------------------------------------------------------- |
| `id`           | string         | The transaction id.                                                                                                 |
| `type`         | string         | The transaction kind.                                                                                               |
| `amount`       | number         | Signed: **positive** for a credit (mint or refund), **negative** for a debit (spend).                               |
| `reason`       | string \| null | Free text - the `reason` from a mint, or a system label. May be `null`.                                             |
| `redemptionID` | string \| null | Set on a spend or refund to link the transaction to its [redemption](/guides/redemptions); `null` for a plain mint. |
| `createdAt`    | string         | ISO-8601 timestamp.                                                                                                 |

<Info>
  `amount` is signed, so summing the page reconciles against the wallet:
  credits push `balance` and `amountReceived` up, debits push `balance` down and
  `amountSpent` up. Follow `redemptionID` to tie a debit (and any later refund)
  back to the redemption that caused it.
</Info>

### Paginate the ledger

Keep passing `nextCursor` back as `cursor` until the server returns `null`:

```bash theme={null}
# First page returns a nextCursor; pass it back to get the next page.
curl "https://api.expys.com/v1/wallet/transactions?limit=50&cursor=CURSOR_FROM_PREVIOUS_PAGE" \
  -H "Authorization: Bearer YOUR_MEMBER_TOKEN"
```

The cursor loop is identical to every other list endpoint in the API, such as
[offers](/guides/offers) and the concierge message history.

## The loop, end to end

<Steps>
  <Step title="Mint">
    Your backend credits points with the Org-API-Key
    (`POST /v1/wallet/credit`). The balance goes up; a credit transaction is
    written.
  </Step>

  <Step title="Spend">
    The member redeems an offer in the app. `pointsPrice` is debited; a
    negative transaction with the `redemptionID` is written.
  </Step>

  <Step title="Refund">
    If that redemption is canceled, `pointsPrice` is credited back; a positive
    transaction carrying the same `redemptionID` is written.
  </Step>

  <Step title="Audit">
    `GET /v1/wallet` shows the running balance and lifetime totals;
    `GET /v1/wallet/transactions` replays the whole history.
  </Step>
</Steps>

## Related

<CardGroup cols={2}>
  <Card title="Wallet" icon="wallet" href="/guides/wallet">
    The wallet object, balances, and currency in depth.
  </Card>

  <Card title="Redemptions" icon="ticket" href="/guides/redemptions">
    How spending and refunds work through the redemption lifecycle.
  </Card>

  <Card title="Members" icon="user" href="/guides/members">
    Identifying members by `externalUserID` and their tier.
  </Card>

  <Card title="Server mode" icon="server" href="/guides/server-mode">
    Backend calls with the Org-API-Key, including minting points.
  </Card>
</CardGroup>
