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

# Redemptions

> Redeem an offer, spend points safely with an idempotency key, and follow the redemption lifecycle from submitted to completed.

A redemption books an offer for a member. Creating one **debits** the offer's
`pointsPrice` from the member's wallet; canceling one **refunds** those points.
This guide covers creating, fetching, and listing redemptions.

<Note>
  These are **member-mode** calls: they use the member token, not the
  Org-API-Key. See [Authentication](/authentication). Whether the redemption
  lands in sandbox or live is selected by the key the token was minted from - see
  [Environments](/environments).
</Note>

## Create a redemption

`createRedemption(input, options/idempotencyKey?)` calls
`POST /v1/redemptions` and returns `201` with the created `Redemption`. The body
is a `CreateRedemptionRequest`:

| Field            | Type             | Description                                                                     |
| ---------------- | ---------------- | ------------------------------------------------------------------------------- |
| `offer`          | string, required | The `id` of the offer to redeem (from [`listOffers`](/guides/offers)).          |
| `externalUserID` | string           | Names the member when a machine token acts on their behalf. Optional otherwise. |

Redeeming debits the offer's `pointsPrice` from the member's balance. If the
balance is below the price, the call fails with `INSUFFICIENT_POINTS` rather than
going negative.

```bash curl theme={null}
curl -X POST https://api.expys.com/v1/redemptions \
  -H "Authorization: Bearer YOUR_MEMBER_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: a1b2c3d4-0000-0000-0000-000000000000" \
  -d '{ "offer": "offer_123" }'
```

### Safe retries with an idempotency key

Because creating a redemption spends points, always send an `Idempotency-Key`
header (the SDK accepts it via `idempotencyKey`). If a network blip makes you
retry, the server returns the original redemption instead of charging twice.
Reusing a key with a different body is rejected with `IDEMPOTENCY_KEY_REUSED`.

<Warning>
  Generate a fresh, unique key per logical redemption attempt and reuse that same
  key across retries of that one attempt. See
  [Retries and idempotency](/guides/retries-and-idempotency) for the full
  contract and how the SDK handles automatic retries.
</Warning>

## Fetch a redemption

`getRedemption(id)` calls `GET /v1/redemptions/{id}` and returns a single
`Redemption`:

| Field       | Type           | Description                                        |
| ----------- | -------------- | -------------------------------------------------- |
| `id`        | string         | The redemption id.                                 |
| `offer`     | Offer          | The redeemed offer (see [Offers](/guides/offers)). |
| `status`    | string         | The current lifecycle status (see below).          |
| `createdAt` | string         | ISO-8601 creation time.                            |
| `startAt`   | string \| null | When the experience starts, if scheduled.          |
| `endAt`     | string \| null | When the experience ends, if scheduled.            |

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

## List redemptions

`listRedemptions(...)` calls `GET /v1/redemptions` and returns a
`ListRedemptionsResponse` with `redemptions` and a `nextCursor`. It accepts
`limit`, `cursor`, `externalUserID`, and a `status` filter. The snippet below
pages through a member's `OPEN` redemptions:

<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,
  });

  const externalUserID = process.env.EXPYS_EXTERNAL_USER_ID;

  async function main(): Promise<void> {
    // Cursor-paginate the member's open redemptions until nextCursor is null.
    let cursor: string | undefined;
    do {
      const page = await expys.listRedemptions({
        cursor,
        externalUserID,
        limit: 50,
        status: "OPEN",
      });
      for (const redemption of page.redemptions) {
        console.log(`redemption ${redemption.id} [${redemption.status}]`);
      }
      cursor = page.nextCursor ?? undefined;
    } while (cursor);

    // The points ledger: each credit/debit on the member's wallet.
    const ledger = await expys.walletTransactions({ externalUserID, limit: 50 });
    for (const transaction of ledger.transactions) {
      console.log(
        `tx ${transaction.id}: ${transaction.type} ${transaction.amount} ` +
          `(${transaction.reason ?? "no reason"})`,
      );
    }
  }
  ```

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

  @main
  struct RedemptionsListExample {
    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 externalUserID = environment["EXPYS_EXTERNAL_USER_ID"]

      let client = ExpysClient(
        configuration: ExpysConfiguration(token: token, environment: .sandbox, baseURL: baseURL)
      )

      // Cursor-paginate the member's open redemptions until nextCursor is nil.
      var cursor: String?
      repeat {
        let page = try await client.listRedemptions(
          status: "OPEN", limit: 50, cursor: cursor, externalUserID: externalUserID)
        for redemption in page.redemptions {
          print("redemption \(redemption.id) [\(redemption.status)]")
        }
        cursor = page.nextCursor
      } while cursor != nil

      // The points ledger: each credit/debit on the member's wallet.
      let ledger = try await client.walletTransactions(limit: 50, externalUserID: externalUserID)
      for transaction in ledger.transactions {
        print(
          "tx \(transaction.id): \(transaction.type) \(transaction.amount) "
            + "(\(transaction.reason ?? "no reason"))")
      }
    }
  }
  ```

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

  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 externalUserID = System.getenv("EXPYS_EXTERNAL_USER_ID")

    val client = ExpysClient.create(
      ExpysConfiguration(
        token = token,
        baseUrl = System.getenv("EXPYS_BASE_URL") ?: ExpysConfiguration.DEFAULT_BASE_URL,
      ),
    )

    // Cursor-paginate the member's open redemptions until nextCursor is null.
    var cursor: String? = null
    do {
      val page = client.listRedemptions(status = "OPEN", limit = 50, cursor = cursor, externalUserID = externalUserID)
      for (redemption in page.redemptions) {
        println("redemption ${redemption.id} [${redemption.status}]")
      }
      cursor = page.nextCursor
    } while (cursor != null)

    // The points ledger: each credit/debit on the member's wallet.
    val ledger = client.walletTransactions(limit = 50, externalUserID = externalUserID)
    for (transaction in ledger.transactions) {
      println("tx ${transaction.id}: ${transaction.type} ${transaction.amount} (${transaction.reason ?: "no reason"})")
    }
  }
  ```
</CodeGroup>

| Parameter        | Type    | Description                                                              |
| ---------------- | ------- | ------------------------------------------------------------------------ |
| `limit`          | integer | Maximum redemptions per page.                                            |
| `cursor`         | string  | The `nextCursor` from the previous page. Omit on the first call.         |
| `externalUserID` | string  | Filter to a specific member (when a machine token acts on their behalf). |
| `status`         | string  | Filter to one lifecycle status (see below).                              |

<Note>
  `ListRedemptionsResponse` is cursor-paginated: follow `nextCursor` until it is
  `null`. Treat the cursor as an opaque token.
</Note>

## Status lifecycle

A redemption moves through these statuses as the experience is booked and
fulfilled:

| Status              | Meaning                                               |
| ------------------- | ----------------------------------------------------- |
| `SUBMITTED`         | The redemption was just created.                      |
| `OPEN`              | It is open and being processed.                       |
| `AWAITING_VENDOR`   | Waiting on the vendor.                                |
| `AWAITING_CUSTOMER` | Waiting on the customer.                              |
| `PURCHASED`         | The booking is purchased.                             |
| `COMPLETED`         | The experience completed.                             |
| `CANCELED`          | The redemption was canceled; the points are refunded. |

<Note>
  Canceling a redemption **refunds** the debited points back to the member's
  wallet. See [Points and wallet](/guides/points-and-wallet) for how the balance
  reflects debits and refunds.
</Note>

## Errors

Branch on the error `code`, never on `message`. The redemption-specific codes
are:

| Code                        | Status | Meaning                                                  |
| --------------------------- | ------ | -------------------------------------------------------- |
| `INSUFFICIENT_POINTS`       | 422    | The member's balance is below the offer's `pointsPrice`. |
| `REDEMPTION_ALREADY_EXISTS` | 409    | The member already redeemed this offer.                  |
| `OFFER_UNAVAILABLE`         | 422    | The offer is expired or otherwise not redeemable.        |

See [Errors](/guides/errors) for the full taxonomy, the shared error shape, and
the `requestId` you quote to support.

## Next steps

<CardGroup cols={2}>
  <Card title="Points and wallet" icon="coins" href="/guides/points-and-wallet">
    How debits and refunds move the member's balance.
  </Card>

  <Card title="Retries and idempotency" icon="arrows-rotate" href="/guides/retries-and-idempotency">
    The idempotency-key contract and automatic retry behavior.
  </Card>

  <Card title="Errors" icon="triangle-exclamation" href="/guides/errors">
    Stable codes, the error shape, and the request id.
  </Card>
</CardGroup>
