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

# Reseller API

> Programmatic wholesale purchasing for resellers of a Shoppex store.

The Reseller API lets an approved reseller browse a merchant's wholesale catalog, check their
balance, and buy stock programmatically. These are the same operations the Reseller tab of the
customer portal exposes, but as a machine interface.

It is a **separate API from the merchant Developer API**. Different base path, different
credential family, different key prefix. A merchant automating their own reseller program uses
[`/dev/v1/resellers`](/developers/manage-resellers) instead.

|                        | Reseller API                  | Developer API       |
| ---------------------- | ----------------------------- | ------------------- |
| Base path              | `/reseller/v1`                | `/dev/v1`           |
| Key prefix             | `shxr_`                       | `shx_`              |
| Who holds the key      | the reseller                  | the merchant        |
| Where keys are created | customer portal, Reseller tab | dashboard, API keys |

<Note>
  Wholesale orders are paid from your reseller balance, not with a card. Top up your balance in
  the portal before ordering. This API has no payment step.
</Note>

***

## Authentication

Send your key as a bearer token on every request:

```bash theme={"system"}
curl https://api.shoppex.io/reseller/v1/me \
  -H "Authorization: Bearer shxr_your_key_here"
```

Keys are created in the customer portal of the merchant's store, under the **Reseller** tab.
The full key is shown once at creation. Shoppex only stores its hash, so a lost key cannot be
recovered, only replaced. Revoking a key is permanent.

The key determines both the shop and the reseller identity. Passing a shop or reseller
identifier in the request is not possible and any such parameter is ignored.

### 401 — invalid, unknown, or revoked key

Returned for a missing or malformed `Authorization` header, an unrecognised key, and a revoked
key. The message is always `Invalid API key.` Shoppex does not distinguish the cases, so a
revoked key cannot be used to probe which keys ever existed.

### 403 — program disabled or account not active

`The reseller program of this shop is not active.` when the merchant turned the program off.
`This reseller account is suspended.` when the merchant suspended you. Any other non-active
status returns `This reseller account cannot use the API.`

### 429 — rate limited

See [Rate limits](#rate-limits). Retry after the window in `retry-after`.

***

## Errors

Every failure uses one envelope:

```json theme={"system"}
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Validation failed",
    "doc_url": "https://docs.shoppex.io/reseller-api/errors",
    "details": [
      { "field": "per_page", "message": "per_page must be 100 or less" }
    ]
  }
}
```

`details` appears only on `VALIDATION_ERROR`. `error.balance` appears only on
`INSUFFICIENT_BALANCE`.

| Code                       | Status | When                                                                         |
| -------------------------- | ------ | ---------------------------------------------------------------------------- |
| `UNAUTHORIZED`             | 401    | Missing, unknown, or revoked key                                             |
| `FORBIDDEN`                | 403    | Program disabled, or reseller not `ACTIVE`                                   |
| `IDEMPOTENCY_KEY_REQUIRED` | 400    | `Idempotency-Key` missing or longer than 128 characters on order create      |
| `INSUFFICIENT_BALANCE`     | 402    | Wholesale total is more than available balance                               |
| `NOT_FOUND`                | 404    | Generic not found                                                            |
| `ORDER_NOT_FOUND`          | 404    | Unknown order, or an order belonging to another reseller                     |
| `PRODUCT_NOT_FOUND`        | 404    | Product not enrolled in the program, or unknown                              |
| `CONFLICT`                 | 409    | Idempotency-key reuse with a different payload, or a request still in flight |
| `VALIDATION_ERROR`         | 422    | Body or query failed validation                                              |
| `RATE_LIMITED`             | 429    | More requests sent than the rate limit allows                                |
| `INTERNAL_ERROR`           | 500    | Unexpected server error                                                      |

Every response carries an `x-request-id` header, whether it succeeds or fails. Quote it when
reporting a problem to the merchant.

<Note>
  An order belonging to a different reseller returns `ORDER_NOT_FOUND`, not `403`. Ownership is
  checked before existence, so the API never reveals that an order exists but is not yours.
</Note>

***

## Idempotency

`POST /reseller/v1/orders` **requires** an `Idempotency-Key` header. It is the only endpoint
that requires one, and the only one that reads it.

```bash theme={"system"}
curl -X POST https://api.shoppex.io/reseller/v1/orders \
  -H "Authorization: Bearer shxr_your_key_here" \
  -H "Idempotency-Key: restock-2026-07-24-001" \
  -H "Content-Type: application/json" \
  -d '{"items":[{"product_id":"019cb402-5555-7000-8000-000000000005","quantity":5}]}'
```

Rules:

* **Same key, same payload** → the stored response is replayed. No second order, no second
  charge. The response carries `idempotency-status: cached`, and `data.order.reused` is `true`.
* **Same key, different payload** → `409 CONFLICT`.
* **Same key while the first call is still running** → `409 CONFLICT` with
  `idempotency-status: processing`. Retry shortly.
* **Keys live 24 hours.** After that the key is free again and a reuse creates a real order.
* Maximum key length is 128 characters. `X-Idempotency-Key` is accepted as a synonym.

<Warning>
  **4xx responses are cached.** A `402 INSUFFICIENT_BALANCE` replayed under the same key returns
  the same 402, even after you have topped up. Use a **new** Idempotency-Key after a top-up.
  Otherwise the retry keeps failing for 24 hours.
</Warning>

Only `5xx` and `429` responses are never cached, so a transient outage cannot permanently
poison a key. Beyond the cache, the order itself carries an idempotency hash in the database.
A duplicate cannot be charged twice even if the cache is unavailable. That backstop answers
the same way the cache does: the original order for the same basket, or `409 CONFLICT`
for a different one.

The `idempotency-status` response header tells you which path ran: `created`, `cached`,
`processing`, or `bypassed`.

***

## Rate limits

A token bucket per key: **30 requests burst, refilling 10 tokens every 2 seconds**. It applies
uniformly to every endpoint, reads included.

| Header                  | Meaning                                                 |
| ----------------------- | ------------------------------------------------------- |
| `x-ratelimit-limit`     | Bucket capacity                                         |
| `x-ratelimit-remaining` | Tokens left                                             |
| `x-ratelimit-reset`     | Seconds until the next refill                           |
| `retry-after`           | Seconds to wait, sent only when the request is rejected |

Rejected requests return `429` with code `RATE_LIMITED`. An invalid key never consumes a token.

***

## GET /reseller/v1/me

Your identity, tier, and progress toward the next tier.

```bash theme={"system"}
curl https://api.shoppex.io/reseller/v1/me \
  -H "Authorization: Bearer shxr_your_key_here"
```

```json theme={"system"}
{
  "data": {
    "shop": {
      "id": "019cb402-0000-7000-8000-000000000000",
      "name": "Demo Store",
      "slug": "demo",
      "currency": "EUR"
    },
    "reseller": {
      "id": "019cb402-1111-7000-8000-000000000001",
      "status": "ACTIVE",
      "email": "wholesale@example.com"
    },
    "tier": {
      "id": "019cb402-3333-7000-8000-000000000003",
      "name": "Gold",
      "discount_percent": 15
    },
    "progress": {
      "lifetime_revenue": "120.00",
      "lifetime_orders": 4,
      "next_tier": {
        "id": "019cb402-4444-7000-8000-000000000004",
        "name": "Platinum",
        "discount_percent": 25,
        "requirements": { "revenue": "500.00", "orders": 20, "requires_both": false },
        "remaining": { "revenue": "380.00", "orders": 16 }
      }
    }
  }
}
```

`next_tier` is `null` when no higher tier has an auto-advance threshold configured. Inside
`requirements`, `revenue` and `orders` are independently nullable. The merchant can configure
only one of them. `requires_both: false` means either threshold alone advances you.

***

## GET /reseller/v1/balance

```json theme={"system"}
{
  "data": {
    "currency": "EUR",
    "balance": "200.00",
    "hold_balance": "10.00",
    "available": "190.00",
    "is_active": true
  }
}
```

`available` is `balance - hold_balance` and is the figure an order is checked against. A
reseller who has never topped up reads all zeros.

***

## GET /reseller/v1/products

The wholesale catalog: only products the merchant enrolled in the program, priced for your
tier.

| Query      | Type                  | Default |
| ---------- | --------------------- | ------- |
| `search`   | string, max 128 chars | —       |
| `page`     | integer ≥ 1           | `1`     |
| `per_page` | integer 1–100         | `25`    |

```json theme={"system"}
{
  "data": [
    {
      "product_id": "019cb402-5555-7000-8000-000000000005",
      "uniqid": "prod_1",
      "title": "Pro License",
      "type": "SERIALS",
      "stock": 12,
      "supplier_backed": false,
      "orderable": true,
      "currency": "EUR",
      "base_price": "100.00",
      "wholesale_price": "85.00",
      "discount_percent": 15,
      "applied_override": "tier",
      "volume_discount_enabled": true,
      "variants": [
        {
          "variant_id": "019cb402-6666-7000-8000-000000000006",
          "title": "1 month",
          "stock": 5,
          "supplier_backed": false,
          "orderable": true,
          "base_price": "50.00",
          "wholesale_price": "42.50",
          "discount_percent": 15,
          "applied_override": "tier"
        }
      ]
    }
  ],
  "pagination": { "page": 1, "per_page": 25, "total": 1 },
  "currency": "EUR"
}
```

`applied_override` tells you which rung of the price cascade set your price. It is `variant`
(a per-variant override), `product` (a per-product override), or `tier` (your tier's flat
discount). `stock: -1` means unlimited.

Check `orderable`, not `stock`, before you order a row. `stock` is the merchant's own local
stock. When `supplier_backed` is `true`, the merchant fulfils that product through their own
supplier. The row carries no local units by design. Its real quantity limit sits with
that supplier, so this API does not report a number for it. A row with
`"orderable": false` is the only one `POST /reseller/v1/orders` refuses as out of stock.

On a product that has variants, the top-level `orderable` is a gate for the **whole
product**. It is `true` when the product itself is orderable **or** at least one of its
variants is. So filtering a listing on the top-level flag never hides something you can
buy. But once a product is in, still check each variant's own `orderable` to pick the option
to order. `supplier_backed` is never summed up across rows: it always describes the exact row it sits on.

***

## POST /reseller/v1/orders

Buys stock at wholesale price and pays from your balance in one step. Requires
`Idempotency-Key`.

```json theme={"system"}
{
  "items": [
    { "product_id": "019cb402-5555-7000-8000-000000000005", "variant_id": null, "quantity": 5 }
  ]
}
```

* 1–100 items per order, each `quantity` an integer between 1 and 100000.
* `variant_id` is optional. Omit or send `null` for products without variants.

Returns `201`:

```json theme={"system"}
{
  "data": {
    "order": {
      "uniqid": "019cb402-7777-7000-8000-000000000007",
      "url": "https://checkout.shoppex.io/invoice/019cb402-7777-7000-8000-000000000007",
      "status": "COMPLETED",
      "currency": "EUR",
      "total": "425.00",
      "created_at": "2026-07-24T09:00:00.000Z",
      "reused": false,
      "tier": {
        "id": "019cb402-3333-7000-8000-000000000003",
        "name": "Gold",
        "discount_percent": 15
      },
      "items": [
        {
          "product_id": "019cb402-5555-7000-8000-000000000005",
          "variant_id": null,
          "quantity": 5,
          "base_price": "100.00",
          "unit_price": "85.00",
          "discount_percent": 15,
          "line_total": "425.00",
          "volume_discount_applied": "0.00"
        }
      ]
    },
    "deliverables": [
      {
        "product_id": "019cb402-5555-7000-8000-000000000005",
        "product_title": "Pro License",
        "variant_id": null,
        "variant_title": null,
        "quantity": 5,
        "delivered": true,
        "delivered_at": "2026-07-24T09:00:01.000Z",
        "serials": ["CODE-1", "CODE-2", "CODE-3", "CODE-4", "CODE-5"]
      }
    ],
    "partially_delivered": false,
    "refunded_amount": null
  }
}
```

Read the deliverables, not just the status. If the merchant ran short of stock, the order still
completes for what can be delivered. The undeliverable lines come back with
`delivered: false` and an empty `serials` array. `partially_delivered` becomes `true`, and
`refunded_amount` carries the amount credited back to your balance.

`partially_delivered` also becomes `true` when a line delivered fewer codes than it ordered.
Compare `serials` against `quantity` per line rather than assuming an all-or-nothing line.
`refunded_amount` is the total credited back to your balance for this order, whatever caused
the refund.

When your balance is too low, the call returns `402` and tells you the gap:

```json theme={"system"}
{
  "error": {
    "code": "INSUFFICIENT_BALANCE",
    "message": "Your reseller balance is not sufficient for this order.",
    "doc_url": "https://docs.shoppex.io/reseller-api/errors",
    "balance": { "required": "425.00", "available": "190.00", "currency": "EUR" }
  }
}
```

Top up in the portal, then retry **with a new Idempotency-Key**. The 402 is cached under the
old one.

***

## GET /reseller/v1/orders

| Query      | Type          | Default |
| ---------- | ------------- | ------- |
| `page`     | integer ≥ 1   | `1`     |
| `per_page` | integer 1–100 | `25`    |

```json theme={"system"}
{
  "data": [
    {
      "uniqid": "019cb402-7777-7000-8000-000000000007",
      "status": "COMPLETED",
      "currency": "EUR",
      "total": "425.00",
      "item_count": 1,
      "created_at": "2026-07-24T09:00:00.000Z"
    }
  ],
  "pagination": { "page": 1, "per_page": 25, "total": 1 }
}
```

***

## GET /reseller/v1/orders/{uniqid}

One order with its delivered codes. This is the endpoint to poll or re-read when you need the
serials again. They are the same codes a normal buyer sees.

```json theme={"system"}
{
  "data": {
    "uniqid": "019cb402-7777-7000-8000-000000000007",
    "status": "COMPLETED",
    "currency": "EUR",
    "total": "425.00",
    "item_count": 1,
    "created_at": "2026-07-24T09:00:00.000Z",
    "tier_name": "Gold",
    "tier_discount_percent": 15,
    "partially_delivered": false,
    "refunded_amount": null,
    "lines": [
      {
        "product_id": "019cb402-5555-7000-8000-000000000005",
        "product_title": "Pro License",
        "variant_id": null,
        "variant_title": null,
        "quantity": 5,
        "unit_price": "85.00",
        "total": "425.00",
        "delivered": true,
        "delivered_at": "2026-07-24T09:00:01.000Z",
        "serials": ["CODE-1", "CODE-2", "CODE-3", "CODE-4", "CODE-5"]
      }
    ]
  }
}
```

`serials` is always an array. It is empty when nothing has been delivered for that line, and
never `null`. `delivered` is `delivered_at !== null`.

***

## Conventions

* Money is always a **decimal string** (`"425.00"`), never a float. Percentages are numbers.
* Timestamps in `data` are ISO 8601 strings.
* Lists return `data` plus `pagination` (`page`, `per_page`, `total`). Pagination is page-based, not cursor-based.
* Field names are `snake_case` throughout.
