> ## 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 — the same operations the Reseller tab of the customer
portal exposes, 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`](/api-reference/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 |

<Info>
  Wholesale orders are paid from your reseller balance, not with a card. Top up your balance in
  the portal before ordering — there is no payment step in this API.
</Info>

***

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

<AccordionGroup>
  <Accordion title="401 — invalid, unknown, or revoked key" icon="lock">
    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.
  </Accordion>

  <Accordion title="403 — program disabled or account not active" icon="ban">
    `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.`
  </Accordion>

  <Accordion title="429 — rate limited" icon="gauge-high">
    See [Rate limits](#rate-limits). Retry after the window in `retry-after`.
  </Accordion>
</AccordionGroup>

***

## 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 exceeds 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    | Rate limit exceeded                                                          |
| `INTERNAL_ERROR`           | 500    | Unexpected server error                                                      |

Every response — success or failure — carries an `x-request-id` header. 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 will keep 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,
so a duplicate cannot be charged twice even if the cache is unavailable — and that backstop
answers the same way the cache does: the original order for the same basket, `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 — only sent when the request was 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 may have
configured 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: `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, and when `supplier_backed` is `true` the merchant fulfils that product through their
own supplier — the row carries no local units by design and 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 could
buy — but once a product is in, still check each variant's own `orderable` to pick the option
to order. `supplier_backed` is never aggregated: 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 could 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,
so 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 would see.

```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, empty when nothing has been delivered for that line — never
`null`. `delivered` is simply `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`) — page-based, not cursor-based.
* Field names are `snake_case` throughout.

***

## Next Steps

<CardGroup cols={2}>
  <Card title="Merchant reseller API" icon="store" href="/api-reference/resellers">
    Automate the program from the merchant side.
  </Card>

  <Card title="Reseller webhooks" icon="bell" href="/api-reference/webhooks/events">
    `reseller:*` events and their payloads.
  </Card>
</CardGroup>
