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

# Consumer API

> Routes called from inside the webview (or your own UI) on behalf of an end user.

All consumer routes are JWT-authenticated (`Authorization: Bearer <jwt>`) with
the **same partner-issued token you use for the webview**. The `/me/*` action
routes have full schemas with try-it-out in the **[API Reference](/api-reference)**
tab; the read-only merchant browse routes are documented under
[Browsing merchants](#browsing-merchants) below.

## Endpoints at a glance

| Method   | Path                         | Purpose                                                 |
| -------- | ---------------------------- | ------------------------------------------------------- |
| `GET`    | `/merchants`                 | List active merchants for a catalogue / listing screen. |
| `GET`    | `/merchants/{id}`            | One merchant with its active outlets.                   |
| `GET`    | `/me`                        | Bootstrap call. User + your brand block.                |
| `GET`    | `/me/claims`                 | List the user's wallet claims.                          |
| `POST`   | `/me/claims`                 | Add a wallet-flow voucher to the user's wallet.         |
| `GET`    | `/me/redemptions`            | List the user's redemption history.                     |
| `POST`   | `/me/redemptions`            | Atomically redeem a voucher at an outlet.               |
| `GET`    | `/me/favorites`              | List merchants the user has favorited.                  |
| `POST`   | `/me/favorites`              | Favorite a merchant.                                    |
| `DELETE` | `/me/favorites/{merchantId}` | Unfavorite a merchant.                                  |

## Browsing merchants

Use these to render a brand catalogue in your own UI — e.g. a merchant
listing screen — before or alongside opening the webview. Both use the
**same partner-issued consumer JWT** as the webview, and both honour your
partner **scope**: if your account is restricted to an allowlist of brands or
categories, only in-scope merchants are returned (out-of-scope ids 404).

### `GET /merchants`

Returns an array of active merchants, ordered alphabetically by `name` (or by
curated order when `featured=true`, or nearest-first when live `lat`/`lng`
are supplied).

Optional query parameters:

| Param           | Type   | Description                                                       |
| --------------- | ------ | ----------------------------------------------------------------- |
| `category`      | string | Filter by category slug (e.g. `F_AND_B`).                         |
| `subCategoryId` | uuid   | Filter by sub-category.                                           |
| `featured`      | `true` | Only admin-curated featured brands, in curated order.             |
| `isNew`         | `true` | Only merchants currently flagged "new".                           |
| `lat`, `lng`    | number | User coordinates — adds `distanceMeters` and sorts nearest-first. |

Each merchant object:

| Field            | Type           | Notes                                                        |
| ---------------- | -------------- | ------------------------------------------------------------ |
| `id`             | uuid           | Merchant id — pass to `/merchants/{id}` or open the webview. |
| `name`           | string         | Display name.                                                |
| `slug`           | string         | URL-safe identifier.                                         |
| `category`       | string         | Category slug.                                               |
| `logoPath`       | string \| null | Logo image — see [Image paths](#image-paths).                |
| `bannerPath`     | string \| null | Banner image.                                                |
| `description`    | string \| null | Short blurb.                                                 |
| `isNew`          | boolean        | Whether the "new" badge applies.                             |
| `subCategoryIds` | uuid\[]        | Sub-categories tagged to this merchant.                      |
| `favorited`      | boolean        | Whether the calling user has favorited it.                   |
| `topVoucher`     | object \| null | Highlighted offer: `{ id, title, imagePath }`.               |
| `distanceMeters` | number \| null | Distance to nearest outlet — only when `lat`/`lng` supplied. |

```json theme={null}
[
  {
    "id": "8a15c0de-1f23-4ab6-9c00-000000000001",
    "name": "Acme Coffee",
    "slug": "acme-coffee",
    "category": "F_AND_B",
    "logoPath": "/uploads/acme-logo.png",
    "bannerPath": "/uploads/acme-banner.png",
    "description": "Specialty coffee roasters.",
    "isNew": true,
    "subCategoryIds": ["b2c1..."],
    "favorited": false,
    "topVoucher": { "id": "c3d4...", "title": "Free latte", "imagePath": "/uploads/latte.png" },
    "distanceMeters": 320
  }
]
```

### `GET /merchants/{id}`

Returns one merchant (same fields as above) plus its active `outlets`.
Returns `404 not_found` if the merchant doesn't exist, is inactive, or is
outside your partner scope.

Additional field:

| Field     | Type      | Notes                                                                                                                                                          |
| --------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `outlets` | object\[] | Active outlets. Each: `id`, `name`, `addressLine`, `addressFull`, `city`, `state`, `postalCode`, `countryCode`, `latitude`, `longitude`, `hasPin`, `isActive`. |

```json theme={null}
{
  "id": "8a15c0de-1f23-4ab6-9c00-000000000001",
  "name": "Acme Coffee",
  "category": "F_AND_B",
  "logoPath": "/uploads/acme-logo.png",
  "bannerPath": "/uploads/acme-banner.png",
  "description": "Specialty coffee roasters.",
  "isNew": true,
  "subCategoryIds": ["b2c1..."],
  "favorited": false,
  "outlets": [
    {
      "id": "d4e5...",
      "name": "Acme — Bangsar",
      "addressLine": "12 Jalan Maarof",
      "addressFull": "12 Jalan Maarof, Bangsar, 59000 Kuala Lumpur",
      "city": "Kuala Lumpur",
      "state": "Wilayah Persekutuan",
      "postalCode": "59000",
      "countryCode": "MY",
      "latitude": 3.13,
      "longitude": 101.67,
      "hasPin": true,
      "isActive": true
    }
  ]
}
```

### Image paths

`logoPath`, `bannerPath`, and voucher `imagePath` are storage paths, not
always absolute URLs. If a value begins with `/uploads/…`, prefix it with the
API origin (`https://vouchersx.com/uploads/…`) to load the image. If it is
already an `https://…` URL, use it as-is.

## Redemption — the important one

`POST /me/redemptions` is the only complex endpoint. A redemption is
**atomic** — the PIN check, GPS distance check, redemption limit, and code
dispensing all happen inside a single transaction. Either everything
succeeds or no row is written.

### Rate limiting

5 attempts per user per **60-second sliding window**. The 6th attempt
returns HTTP 429 with a `retry-after` header (seconds):

```json theme={null}
{ "error": "rate_limited", "details": { "retryAfterSeconds": 42 } }
```

This guards against PIN brute-forcing.

### GPS rules

| GPS state                                          | Result                                                                                                                                                    |
| -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Coordinates supplied AND within **150m** of outlet | `status: "SUCCESS"`                                                                                                                                       |
| Coordinates supplied but > 150m away               | **Rejected** with `too_far` — no row written. Includes `details: { distanceMeters, maxMeters }`.                                                          |
| Coordinates omitted                                | Redemption succeeds but is recorded as `status: "FLAGGED"`, `flagReason: "gps_unavailable"`.                                                              |
| Outlet has no coordinates configured               | Geofence can't run — `status: "SUCCESS"` with `flagReason: "outlet_not_geolocated"` set as an audit tag for admin follow-up (not a `FLAGGED` redemption). |

`FLAGGED` redemptions (the `gps_unavailable` case) are stored for support
review. They count toward the user's cycle redemption limit and (if the
voucher dispenses codes) **a code is still dispensed**.

### Per-redemption limits

Two independent caps can apply:

* **Per-user cycle quota** (`redemptionCount` on the voucher): how many
  times *this user* can redeem this voucher per cycle (`ONE_TIME`,
  `MONTHLY`, or `BIWEEKLY`). Exceeded → `limit_reached` (422) with
  `details: { limit, used, cycleKey }`.
* **Global cycle cap** (`globalRedemptionCap`): how many times *all users
  combined* can redeem the voucher in the current cycle. Useful for
  capped-inventory promos. Exceeded → `global_cap_reached` (422) with
  `details: { cap, used, cycleKey }`.

Both checks happen inside the same transaction with advisory locks, so two
concurrent redemptions can't both pass the count check.

### Voucher codes

If the voucher's `codeSource` is `MANUAL` or `AUTO`, the response includes a
`code` field — display this to the user. If the voucher has no codes
configured, `code` is `null`.

When the manual code pool is empty, the redemption fails with `codes_exhausted`
(422) — the redemption row is **not** written. The merchant needs to upload
more codes.

### Favorites-only vouchers

If the voucher is marked `favoritesOnly`, the user must have favorited the
merchant first. Otherwise the redemption fails with `not_a_favorite`
(403). Favorite via `POST /me/favorites`.

### Wallet flow

If the voucher's `claimFlow` is `WALLET`, the user must claim it first via
`POST /me/claims`. Otherwise redemption fails with `no_claim` (422).
Instant-flow vouchers skip this step.

### Redemption window (wallet vouchers)

Wallet vouchers can carry an optional redemption window — `applicableFrom`
and/or `applicableUntil` timestamps on the voucher. Claiming is never
blocked (the user can add the voucher to their wallet any time), but
**redemption** is gated to the window:

* Before `applicableFrom` → `voucher_not_yet_applicable` (422) with
  `details: { applicableFrom }`.
* After `applicableUntil` → `voucher_no_longer_applicable` (422) with
  `details: { applicableUntil }`.

Both bounds are independent and optional. Surface the window in your UI so
claimants know when the voucher becomes usable and when it expires.

## Scope enforcement

If your partner has a restricted scope (allowlist of merchants or
categories), every `/me/*` mutation enforces it. Out-of-scope vouchers or
merchants return 403 with `voucher_out_of_scope` / `merchant_out_of_scope`
even if the user knows the UUID. The webview catalog already filters by
scope; this server-side check is the second layer of defence.
