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

# Members and tiers

> Upsert member profiles and tiers, read a member summary with wallet and redemption counts, and remove members - all server-side with the Org-API-Key.

<Warning>
  Member management is **server-side**. These calls use the secret **Org-API-Key**
  on your backend only - **never** ship it in an app or client code. A request
  with a **member token** is rejected with `403`. See
  [Authentication](/authentication) and [Server-side API](/guides/server-mode).
</Warning>

A member is one of your users as Expys knows them, keyed by your own stable
`externalUserID`. From your backend you upsert a member's profile and
[tier](/guides/eligibility), read a full summary of their wallet and redemption
activity, and remove them when needed.

## Upsert a member

`setMember(externalUserID, {...})` calls `PUT /v1/members/{externalUserID}` and
creates the member if they do not exist or updates them if they do. `PUT` is
idempotent by HTTP semantics, so replaying the same body is safe.

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

<ParamField path="tier" type="string">
  A free-form tier string you define (for example `gold`, `vip`). It flows into
  member-mode [eligibility](/guides/eligibility) to gate offers.
</ParamField>

<ParamField path="attributes" type="object">
  Free-form JSON for your own metadata. Stored as-is and returned on
  `getMember`.
</ParamField>

```bash curl theme={null}
curl -X PUT https://api.expys.com/v1/members/user_123 \
  -H "Authorization: Bearer YOUR_ORG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "displayName": "Ada Lovelace",
    "tier": "gold",
    "attributes": { "plan": "annual", "region": "eu" }
  }'
```

The response is a `SetMemberResponse`:

| Field            | Type           | Description                                  |
| ---------------- | -------------- | -------------------------------------------- |
| `externalUserID` | string         | Your stable identifier for the member.       |
| `displayName`    | string \| null | The stored display name, or `null` if unset. |
| `tier`           | string         | The member's tier.                           |
| `attributes`     | object \| null | Your free-form metadata, or `null` if unset. |

<Note>
  `tier` is a string you define - Expys does not enforce a fixed set. Keep tier
  names consistent with the conditions you check in
  [eligibility](/guides/eligibility).
</Note>

## Read a member summary

`getMember(externalUserID)` calls `GET /v1/members/{externalUserID}` and returns
a `MemberSummary`: the profile plus the member's wallet and a per-status count of
their redemptions.

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

<ResponseField name="externalUserID" type="string" required>
  Your stable identifier for the member.
</ResponseField>

<ResponseField name="displayName" type="string">
  The stored display name, or `null` if unset.
</ResponseField>

<ResponseField name="tier" type="string" required>
  The member's tier.
</ResponseField>

<ResponseField name="attributes" type="object">
  Your free-form metadata, or `null` if unset.
</ResponseField>

<ResponseField name="wallet" type="Wallet" required>
  The member's points wallet.
</ResponseField>

<ResponseField name="redemptionCounts" type="RedemptionCounts" required>
  A map of redemption status to count for this member.
</ResponseField>

### Wallet

| Field             | Type   | Description                          |
| ----------------- | ------ | ------------------------------------ |
| `balance`         | number | Current points balance.              |
| `amountReceived`  | number | Total points ever credited.          |
| `amountSpent`     | number | Total points ever spent.             |
| `currency.name`   | string | Display name of the points currency. |
| `currency.symbol` | string | Symbol of the points currency.       |

### Redemption counts

`redemptionCounts` is a per-status count of the member's redemptions across the
booking lifecycle:

| Key                 | Meaning                                 |
| ------------------- | --------------------------------------- |
| `SUBMITTED`         | Redemptions submitted.                  |
| `OPEN`              | Redemptions in the open state.          |
| `AWAITING_VENDOR`   | Waiting on the vendor.                  |
| `AWAITING_CUSTOMER` | Waiting on the customer.                |
| `PURCHASED`         | Purchased (sometimes called confirmed). |
| `COMPLETED`         | Completed experiences.                  |
| `CANCELED`          | Canceled redemptions.                   |

All values are numbers.

## List members

`listMembers({ tier?, limit?, cursor? })` calls `GET /v1/members` and returns a
page of the same `MemberSummary` records, newest-first.

```bash curl theme={null}
curl "https://api.expys.com/v1/members?tier=gold&limit=50" \
  -H "Authorization: Bearer YOUR_ORG_API_KEY"
```

<ParamField query="tier" type="string">
  Return only members whose tier matches this value exactly.
</ParamField>

<ParamField query="limit" type="number">
  Members per page, 1-100. Defaults to 20.
</ParamField>

<ParamField query="cursor" type="string">
  The `nextCursor` from a previous response.
</ParamField>

The response is `{ members, nextCursor }`. Keep passing `nextCursor` back as
`cursor` until it comes back `null`, which means the list is exhausted:

```bash curl theme={null}
curl "https://api.expys.com/v1/members?cursor=eyJpZCI6..." \
  -H "Authorization: Bearer YOUR_ORG_API_KEY"
```

<Note>
  The list only includes members provisioned through the SDK — those with an
  external identity in your org. Archived members are excluded.
</Note>

## Remove a member

`removeMember(externalUserID, { retainBalance? })` calls
`DELETE /v1/members/{externalUserID}` and **archives** the member. The optional
`retainBalance` query parameter controls whether the points balance is kept.

<ParamField query="retainBalance" type="boolean">
  When `true`, the member's points balance is retained on the archived record.
  When omitted or `false`, the balance is not retained.
</ParamField>

```bash curl theme={null}
curl -X DELETE "https://api.expys.com/v1/members/user_123?retainBalance=true" \
  -H "Authorization: Bearer YOUR_ORG_API_KEY"
```

The response is a `RemoveMemberResponse`:

| Field             | Type    | Description                                                     |
| ----------------- | ------- | --------------------------------------------------------------- |
| `externalUserID`  | string  | The member that was removed.                                    |
| `archived`        | boolean | `true` when the member was archived.                            |
| `balanceRetained` | boolean | Whether the points balance was kept (reflects `retainBalance`). |

<Note>
  Removal archives rather than hard-deletes the member, so historical
  redemptions and analytics remain consistent.
</Note>

## In code

The server client is constructed with the Org-API-Key. The example below upserts
a member; `getMember`, `listMembers` and `removeMember` are called the same way.

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

## Next steps

<CardGroup cols={2}>
  <Card title="Eligibility" icon="filter" href="/guides/eligibility">
    How a member's `tier` gates which offers they can redeem in member mode.
  </Card>

  <Card title="Points and wallet" icon="coins" href="/guides/points-and-wallet">
    Mint and credit points into the wallet returned by `getMember`.
  </Card>
</CardGroup>
