# Store API reference

Source: https://rollroyce.store/docs/api

This document is the complete reference for the two API-key surfaces of this
store. It is generated from the same content the documentation page renders,
so it never lags behind it.

---
## Overview

This API lets another system do two things: **buy from the catalogue** the way a
customer does, and **fulfil product orders** the way a collaborator does. Those
are two separate surfaces with two separate kinds of key, and a key for one is
refused by the other.

Everything below is versioned under `/api/v1` and answers JSON.

### Base URL

There is no separate `api.` hostname and no separate port — the API is served
from the same origin as the storefront:

```http
https://rollroyce.store/api/v1
```

### It is server-to-server

The API answers CORS preflights only for the storefront's own origin. A browser
on any other origin cannot call it, and no amount of client-side configuration
will change that — the refusal is not a misconfiguration, it is the deployment.

Call it from your backend. If you need it in a browser, put your own server in
between; that server holds the key, which is where a key belongs anyway.

### The response envelope

Every success looks the same:

```json
{
  "data": {},
  "message": "A human sentence, or null",
  "meta": { "pagination": { "total": 0, "perPage": 20, "currentPage": 1, "lastPage": 1 } }
}
```

`meta` appears **only** on a paginated response. `message` is already translated
for the caller's `Accept-Language`; when you show a result to a person, prefer it
over a sentence of your own — some of these are worded deliberately.

Every failure looks the same too:

```json
{
  "errors": [{ "code": "E_VALIDATION", "field": "quantity", "message": "…" }]
}
```

### Money is a string

Every money value arrives as a **string**, not a number, and that is not a
serialisation quirk to normalise away. The columns behind them hold four decimal
places, and this catalogue prices below a cent — so `"0.0125"` is a real price
and `JSON.parse` into a float will eventually lose someone's money.

Keep it a string, or move it into a decimal type. Never a JavaScript `number`.

**The amount charged is computed by the API, never by you.** Send what you want
to buy; read `paidAmount` back.

### Pagination

Every list takes `page` and `perPage` and returns `meta.pagination`. There is no
unpaginated list anywhere in this API — a listing that looks small today is one
an operator can grow tomorrow.

```http
GET /api/v1/client/catalog/groups?page=2&perPage=50
```

`perPage` above 100 is a **422**, not clamped.

### Query strings

Query strings are parsed with a ceiling of 40 parameters — anything past the
fortieth is dropped, not refused — and shallow nesting. Attribute filters use
one level of it:

```http
GET /api/v1/client/catalog/groups?attributes[region]=EU&attributes[tier]=pro
```

Arrays in query strings are **not** supported. Repeat-key and `a[]=1&a[]=2`
forms will not do what you expect.

---

## Authentication

One header. Not `Authorization`, and not a bearer token — those belong to the
signed-in web session and an API key is refused on every route that uses them.

```http
X-Api-Key: ck_NA.eyJzZWNyZXQiOiJleGFtcGxlIn0
```

### Two surfaces, two prefixes

| Prefix | Key type | Reaches | For |
|---|---|---|---|
| `ck_` | `customer` | `/api/v1/client/*` | Browsing the catalogue, placing and reading your own orders |
| `lk_` | `collaborator` | `/api/v1/collab/*` | Claiming and fulfilling product orders, reading payouts |

A `ck_` key sent to `/collab/*` is a **401**, not a 403 — the same answer as no
key at all. The prefix is a convenience for you and for secret scanners; the
server does not trust it, and checks the key's real type against the surface
after verifying the secret.

### Calling from a server

**Writes need one extra header.** Any request other than `GET`, `HEAD` or
`OPTIONS` that carries neither `Origin` nor `Sec-Fetch-Site` is refused with
**403 `E_FORBIDDEN`** before it reaches the API at all. `Origin` must be the
site's own origin, exactly as shown — any other value is refused the same way.
If you send `Sec-Fetch-Site`, it wins: only `same-origin` and `none` pass.

```http
Origin: https://rollroyce.store
```

A browser sets one of those two headers on its own, so this only ever bites
server-side clients — `curl`, `requests`, `axios` on Node, and anything else
that is not a browser.

> **Why this exists, and why sending it is safe.** The header check is CSRF
> protection for the session cookie: a browser attaches that cookie by itself, so
> a page on another site could otherwise make a state-changing request as a
> signed-in user. `Origin` is set by the browser and page script cannot forge it,
> so a real cross-site attack still cannot produce it. You are not that attack —
> you already hold an API key. Naming the site you are talking to costs nothing
> and keeps the protection intact for everyone else.

`GET` requests never need it.

### What a 401 means

Six different problems answer with the **same** 401, the same `code`, and the
same message:

- no `X-Api-Key` header
- a malformed value
- a key id that does not exist
- a wrong secret
- a disabled key
- a key for the other surface

That is deliberate. Telling them apart would tell an attacker probing with
crafted values which half of a guess was right. When you get a 401, check your
key and your surface — the API will not narrow it down for you.

**Failed authentications are budgeted per IP address: 30 a minute.** Past that,
every request from the address that presents a credential — a wrong one *or a
correct one* — is a **429** with `retry-after` until the window resets, and the
credential is not looked up at all. Only failures count, so a working client
never gets near it; a service retrying a wrong key in a loop takes every other
client behind the same address down with it for the rest of that minute. Fix
the key, do not retry a 401.

A **403** is different, and it does say something real: the key is valid, and
either it lacks the ability the route needs (`E_FORBIDDEN`), the account is
suspended (`E_ACCOUNT_BANNED`), or the collaborator partnership is not live
(`E_COLLABORATOR_NOT_APPROVED`).

### Handle the secret like a password

The plaintext value leaves the server **once**, in the response that creates the
key. Only a hash is stored, so it cannot be shown again and cannot be recovered
— if it is lost, revoke the key and mint another.

Keep it server-side. A key placed in a browser bundle, a mobile app or a public
repository is a key that can spend the wallet behind it.

---

## Getting a key

You mint your own keys, from your own account, while signed in to the site.
Nobody mints one on your behalf — not support, not an administrator. An
administrator can revoke or disable a key, change its rate limit, or change its
abilities after the fact — never read its secret.

The three routes below run on the **session** surface, so they are reached with a
signed-in browser session and **cannot be called with an API key**. That is the
one deliberate circularity in this API, and it is what stops a leaked key from
minting more keys.

> The [playground](/docs/api/playground) cannot exercise these three — it
> authenticates with an API key and holds no session. Use the site while signed
> in.

### Choosing abilities

Omit `abilities` and you get the **default grant** for the key's type: everything
that type can do, minus `product_order:cancel`. See
[Abilities](#abilities) for the full table.

Grant the least the integration needs. A key that only reads the catalogue and
places orders has no business holding `credential:read`, and one that never
cancels should not be able to.

### A collaborator key needs an approved partnership

`type: "collaborator"` is refused with **403** unless your account is an
approved collaborator (`E_COLLABORATOR_NOT_APPROVED`, or
`E_COLLABORATOR_PROFILE_REQUIRED` when there is no partnership at all). Being
*given* the role is not enough — the check reads the partnership's live status,
so a withdrawn partnership stops working immediately, on the key you already
hold.

Minting is limited to **5 keys per 15 minutes** per account, and an account
holds at most **10 keys** at once — the eleventh is a
**422 `E_API_KEY_LIMIT_REACHED`** until one is revoked (an operator can raise
the ceiling for you). A `name` that is already used by another key of the
same type on your account is a 422 on `name`.

### Mint a key

```http
POST /api/v1/account/api-keys
```

The only way a key is ever created — and the only time its secret is shown.

- **Auth**: session bearer token — **an API key cannot call this route**.
- **Changes state**: yes — this is not a safe request to retry blindly

**Request body**

| Field | Type | Constraint | Description |
|---|---|---|---|
| `type` **(required)** | `string` | customer \| collaborator | Which surface the key opens. A collaborator key requires an approved partnership. |
| `name` | `string` | max 100 chars, nullable | A label for you. Unique per account and type. |
| `abilities` | `string[]` | values from the ability table | Omit to get the default grant for the type. An ability outside that type's list is a 422. |



**Response `201`** — The key, plus value — the plaintext secret. This is the ONLY response in the entire API that carries it. Store it now; it is not recoverable.

```json
{
  "data": {
    "id": 4,
    "type": "customer",
    "name": "Storefront integration",
    "abilities": [
      "catalog:read",
      "order:quote",
      "order:create",
      "order:read",
      "credential:read",
      "balance:read"
    ],
    "rateLimitPerMinute": null,
    "status": "active",
    "expiresAt": null,
    "createdAt": "2026-02-01T08:00:00.000Z",
    "value": "ck_NA.eyJzZWNyZXQiOiJleGFtcGxlIn0"
  },
  "message": "API key created"
}
```

**Errors specific to this endpoint**

| Code | Status | Meaning | What to do |
|---|---|---|---|
| `E_API_KEY_LIMIT_REACHED` | 422 | The account already holds as many keys as it is allowed (10 by default). | Revoke a key you no longer use, or ask an operator to raise the ceiling for your account. Nothing about the request will change on its own. |


### List your keys

```http
GET /api/v1/account/api-keys
```

Every key on your account. The secret is never among them.

- **Auth**: session bearer token — **an API key cannot call this route**.
- **Paginated**: the envelope carries `meta.pagination`

**Query parameters**

| Field | Type | Constraint | Description |
|---|---|---|---|
| `page` | `integer` | min 1, default 1 | 1-based page number. |
| `perPage` | `integer` | 1-100, default 20 — above 100 is a 422, not clamped | Rows per page. |



**Response `200`** — A page of keys.

```json
{
  "data": [
    {
      "id": 4,
      "type": "customer",
      "name": "Storefront integration",
      "abilities": [
        "catalog:read",
        "order:quote",
        "order:create",
        "order:read"
      ],
      "rateLimitPerMinute": null,
      "status": "active",
      "lastUsedAt": "2026-02-11T14:03:22.000Z",
      "expiresAt": null,
      "createdAt": "2026-02-01T08:00:00.000Z",
      "updatedAt": "2026-02-01T08:00:00.000Z"
    }
  ],
  "message": null,
  "meta": {
    "pagination": {
      "total": 42,
      "perPage": 20,
      "currentPage": 1,
      "lastPage": 3
    }
  }
}
```


### Revoke a key

```http
DELETE /api/v1/account/api-keys/:id
```

Kills the key on the next request. Orders it placed are kept.

- **Auth**: session bearer token — **an API key cannot call this route**.
- **Changes state**: yes — this is not a safe request to retry blindly

**Path parameters**

| Field | Type | Constraint | Description |
|---|---|---|---|
| `id` **(required)** | `integer` | digits only | The key id. |



**Response `200`** — Gone. `data` is null; the message names what happened.

```json
{
  "data": null,
  "message": "API key deleted"
}
```


> Revoking takes effect on the **next request**. Nothing is cached, so there is
> no window in which a revoked key still works. Orders the key placed are kept —
> revocation removes the credential, not the history. An id that is not one of
> your keys is a **404 `E_API_KEY_NOT_FOUND`**.

---

## Abilities

A key's abilities are chosen when it is minted and **cannot be changed
afterwards** by the person who created it — to widen a key, mint a new one. An
administrator can change them, so a key's grant is not something to hard-code
on your side either.

Every route names the one ability it needs. Holding the wrong set is a
`403 E_FORBIDDEN` — the key is fine, the grant is not.

### Customer keys

| Ability | Grants | In the default grant |
|---|---|---|
| `catalog:read` | Browse categories, listing groups and the products inside one. | yes |
| `order:quote` | Price a basket before committing to it. Creates nothing. | yes |
| `order:create` | Place an order. Debits the account's wallet. | yes |
| `order:read` | Read the key owner's own orders — metadata, never credentials. | yes |
| `credential:read` | Decrypt and read the delivered accounts of the owner's own completed `account` orders. | yes |
| `balance:read` | Read the owner's wallet balance and the discount rate currently applied to it. | yes |


`credential:read` is separate from `order:read` on purpose. Reading an order is
reading **metadata**; reading a credential is **taking the asset**. An
integration that builds reports needs the first and must not hold the second.

### Collaborator keys

| Ability | Grants | In the default grant |
|---|---|---|
| `product_order:read` | The claim queue, the collaborator's own orders, and one order's detail. | yes |
| `product_order:claim` | Take an unclaimed product order out of the queue. | yes |
| `product_order:complete` | Mark a held order delivered, which starts its payout clock. | yes |
| `product_order:cancel` | Cancel an order the collaborator is holding and refund the buyer in full. | **no — must be requested explicitly** |
| `payout:read` | Read the collaborator's own commission payout history. | yes |
| `complaint:read` | Read the complaint items filed against the collaborator's own sales. | yes |
| `complaint:respond` | Answer a complaint item: refund the buyer, or send a replacement / a re-done result. Withheld by default like product_order:cancel: the refund half moves the collaborator's own escrow to the buyer, and a leaked key holding it could do that for every open complaint. | **no — must be requested explicitly** |
| `stock:write` | Upload account credentials into a group's pool, in the collaborator's own name. Granted by default. A leaked key holding it can flood a group's pool with junk rows under your name, list your entire inventory, and delete your own unsold (ready) rows outright — rotate the key. The reach stops there: delete cannot touch a row that is reserved or already sold, so no buyer is affected and no money moves, and it cannot touch another collaborator's inventory — only rows the key owner filed. | yes |


> **`product_order:cancel` is the one ability withheld by default.** It destroys
> a real order and refunds the buyer in full, which means a leaked key holding it
> can do that in bulk. Ask for it only when you need it, and preferably on a key
> that does nothing else.

### Abilities do not cross surfaces

The two lists above are not interchangeable. Minting a `customer` key with
`payout:read` is a **422** — not because the ability is dangerous, but because
it could never be used: `/collab/*` refuses a customer key with a 401 long
before any ability is checked, and a grant that can never fire is a confusing
thing to hand out silently.

### Discounts do not live on the key

A key carries no pricing power of its own. Two different keys on the same
account, ordering the same basket, are charged **exactly the same**.

Whatever discount the *account* has — a manual rate, or one earned by tier —
applies automatically on every order placed with any of its keys. You do not
request it and you cannot alter it.

Discount **codes** are a different thing, and they do not work here at all: see
[the note on `POST /client/orders/quote`](#client.orders.quote).

---

## Rate limits

The surface budget is counted **per key**, not per account and not per IP. Two
keys on one account have independent budgets, so a runaway script on one does
not take the other down with it. The tighter budgets on particular routes are
the exception: those are counted **per account**, shared by every key you hold
and by your session on the website — a second key does not buy a second claim
budget.

### The surface budget

| Key type | Default | Applies to |
|---|---|---|
| `customer` (`ck_`) | **120 requests / minute** | every `/api/v1/client/*` route |
| `collaborator` (`lk_`) | **240 requests / minute** | every `/api/v1/collab/*` route |

Collaborators get the larger budget because they poll a queue; a script checking
four times a second is ordinary there and would be strange on the customer
surface.

An administrator can set a per-key limit that overrides the default, up to a hard
ceiling of 1200/minute. Your key's own limit is on `GET /account/api-keys` as
`rateLimitPerMinute` — `null` means "use the default for the type".

### Routes with a tighter budget of their own

These **stack on top of** the surface budget rather than replacing it. Hitting
either one is a 429.

| Route | Extra budget |
|---|---|
| `GET /client/orders/:code/credentials` | 10 / minute **per account**, shared with the website's own credential views — plus 120,000 credential rows per hour, per account |
| `POST /collab/product-orders/:id/claim` | 20 / minute **per account**, shared with claims made on the website (an operator setting; this is the default) |
| `POST /collab/product-orders/:id/cancel` | 10 / minute **per account**, shared with the website (an operator setting; this is the default) |
| Any request with a credential that fails to authenticate | 30 failures / minute **per IP address** — see [Authentication](#authentication) |

> **A lost claim race still spends a claim.** The budget is charged before the
> handler runs, so a `409 E_PRODUCT_ORDER_ALREADY_CLAIMED` costs you one of your
> twenty. That is worth designing around: poll the queue, claim what you intend
> to work on, and do not claim speculatively.

### Read the headers instead of guessing

Every response carries the state of a budget it just spent from —
`x-ratelimit-reset` is the number of **seconds** until that budget resets:

```http
x-ratelimit-limit: 120
x-ratelimit-remaining: 118
x-ratelimit-reset: 37
```

On a route with a tighter budget of its own, the headers describe **that**
budget, not the surface one. A request refused with a 401 or 403 carries none.

And a refusal tells you exactly how long to wait:

```http
HTTP/1.1 429 Too Many Requests
retry-after: 37
```

Back off on `retry-after`. Do not retry immediately, and do not spread the same
work across several keys to get around a limit — a limit reached that way is a
sign the integration should be batching, not multiplying credentials.

### Windows are fixed, not sliding

A window opens on the first request that touches a budget and resets when
`x-ratelimit-reset` reaches zero; it does not decay gradually. Sixty requests
at the end of one window and sixty at the start of the next are both allowed,
and both are within the rules.

---

## Errors

```json
{
  "errors": [
    { "code": "E_VALIDATION", "field": "items.0.quantity", "message": "…" }
  ]
}
```

`field` is `null` for anything that is not about one input. Nested fields are
dotted, so `items.0.quantity` points at the second line of an order — bind these
to your own inputs rather than showing only the first message.

### Branch on `code`, never on `status`

This is the single most important thing on this page for anyone writing a client.
The status code is not enough to decide what to do, because different problems
share one:

- `E_FORBIDDEN` (403) means **fix the key** — mint one with the right ability.
- `E_ACCOUNT_BANNED` (403) means **stop** — no key on this account will work.
- `E_COLLABORATOR_NOT_APPROVED` (403) means **the partnership ended** — the key
  is still valid and will start working again if it is restored.

Three identical statuses, three unrelated responses. Read `errors[0].code`.

### Codes on every route

| Code | Status | Meaning | What to do |
|---|---|---|---|
| `E_UNAUTHORIZED` | 401 | The key was missing, malformed, unknown, disabled, expired, or belongs to the other surface. | Check the header is X-Api-Key and that the key prefix matches the surface you are calling. All of those causes answer identically on purpose — the API will not tell you which one it was. |
| `E_FORBIDDEN` | 403 | The key authenticated, but does not hold the ability this route requires. | Read the ability named on the endpoint. A key's abilities are fixed when it is created; mint a new key rather than expecting this to change. |
| `E_ACCOUNT_BANNED` | 403 | The account the key acts as is suspended. | Nothing the key can do. Every key on that account is refused until an operator reinstates it. |
| `E_TOO_MANY_REQUESTS` | 429 | A rate limit was exceeded. | Wait for retry-after (seconds). Every response also carries x-ratelimit-remaining and x-ratelimit-reset, so you never have to discover a limit by being refused. |
| `E_NOT_FOUND` | 404 | No route matched. A path parameter of the wrong shape — a non-numeric id, an order code without the ord_ prefix — lands here, before any handler runs. | Check the path. This is the router answering, so it carries no endpoint-specific code. |
| `E_VALIDATION` | 422 | One or more fields failed validation. | Each entry in errors[] carries the offending field — dotted for nested values, e.g. items.0.quantity. Bind them to your own inputs rather than showing the first message alone. |
| `E_INTERNAL_ERROR` | 500 | Something failed inside the API. | Retry idempotent reads. The message is deliberately generic and carries no detail — the detail is in the server log, never in your response. |


Codes raised by particular endpoints are listed with each endpoint.

### Retrying

| Situation | Retry? |
|---|---|
| `429` | Yes, after `retry-after` seconds |
| `500 E_INTERNAL_ERROR` | Yes for reads; for `POST /client/orders`, check the order list first |
| `409 E_INSUFFICIENT_STOCK` | Yes, with a smaller quantity |
| `409 E_PRODUCT_ORDER_ALREADY_CLAIMED` | Yes, against a different order |
| `409 E_GROUP_UNAVAILABLE` | Only after re-reading the group — it may be gone for good |
| `401`, `403`, `404`, `422` | No. Nothing about the request will change on its own |

> **Order creation is not idempotent.** There is no idempotency key in this API.
> If `POST /client/orders` times out, do **not** blindly resend it — read
> `GET /client/orders` first and check whether the order exists. A duplicate here
> is a duplicate charge.

### 500s tell you nothing, deliberately

An internal error carries a fixed generic sentence and no detail — no query, no
table name, no stack. The detail exists, in the server's log. If you need it,
quote the time and the endpoint when you ask.

---

## MCP server

This reference is also an [MCP](https://modelcontextprotocol.io) server. Install
it once and your coding agent can look an endpoint up while it writes the
integration, instead of being handed the whole page and asked to remember it.

Install into a coding agent:

```bash
# Claude Code
claude mcp add --transport http store-api-docs https://rollroyce.store/api/mcp

# Codex CLI
codex mcp add store-api-docs --url https://rollroyce.store/api/mcp
```

Cursor and VS Code install it from a link on the page; Claude Code and Codex
have no MCP install URL scheme, so those two use the commands above.

Or, for any client that reads an MCP config file:

```json
{
  "mcpServers": {
    "store-api-docs": {
      "type": "http",
      "url": "https://rollroyce.store/api/mcp"
    }
  }
}
```

### What the agent gets

Five read-only tools:

| Tool | Answers |
|---|---|
| `list_guides` | The sections that explain how the API works as a whole. |
| `get_guide` | One of them in full — authentication, getting a key, abilities, rate limits, errors. |
| `list_endpoints` | Every endpoint, with method, path, ability and a one-line summary. |
| `get_endpoint` | One endpoint in full: parameters, a runnable example, response shapes, error codes. |
| `search_api_docs` | Keyword search across endpoints, abilities and error codes. |

An agent that only ever reads endpoint pages writes a client that authenticates
with the wrong header and retries a 403 forever, which is why the guide tools
come first and the server's own instructions tell it to start there.

> **The server does not call the API.** It describes it, and nothing more. That
> is a deliberate limit rather than an unfinished feature: the endpoint carries
> no authentication because everything it returns is already public on this site,
> and a tool that made requests would turn an unauthenticated URL into a proxy
> into a service that is otherwise unreachable from the internet. Making a real
> request needs your key — use your own client, or the
> [playground](/docs/api/playground).

### Prefer plain text?

Two files carry the same reference with no protocol at all:

- [`/docs/api/llms.txt`](/docs/api/llms.txt) — a short index of what exists.
- [`/docs/api/llms-full.txt`](/docs/api/llms-full.txt) — the complete reference
  as Markdown, in the order the page reads.

Both are generated from the same source as the page, so neither can fall behind
it.

---

### List categories

```http
GET /api/v1/client/catalog/categories
```

The public category tree, paginated.

- **Auth**: `X-Api-Key` — a `customer` key (`ck_…`)
- **Ability**: `catalog:read`
- **Paginated**: the envelope carries `meta.pagination`

**Query parameters**

| Field | Type | Constraint | Description |
|---|---|---|---|
| `page` | `integer` | min 1, default 1 | 1-based page number. |
| `perPage` | `integer` | 1-100, default 20 — above 100 is a 422, not clamped | Rows per page. |



Categories are the top level of the catalogue. They carry no prices and no stock
of their own — they exist to group listings, and `displayPriority` is the order
an operator wants them shown in.

**Example request**

```bash
curl -X GET 'https://rollroyce.store/api/v1/client/catalog/categories' \
  -H 'X-Api-Key: YOUR_API_KEY'
```

**Response `200`** — A page of categories.

```json
{
  "data": [
    {
      "id": 3,
      "name": "Example category",
      "slug": "example-category",
      "description": null,
      "image": null,
      "displayPriority": 0,
      "createdAt": "2026-01-04T09:12:00.000Z"
    }
  ],
  "message": null,
  "meta": {
    "pagination": {
      "total": 42,
      "perPage": 20,
      "currentPage": 1,
      "lastPage": 3
    }
  }
}
```

---

### List groups

```http
GET /api/v1/client/catalog/groups
```

Listing groups, filterable and sortable. This is the catalogue.

- **Auth**: `X-Api-Key` — a `customer` key (`ck_…`)
- **Ability**: `catalog:read`
- **Paginated**: the envelope carries `meta.pagination`

**Query parameters**

| Field | Type | Constraint | Description |
|---|---|---|---|
| `page` | `integer` | min 1, default 1 | 1-based page number. |
| `perPage` | `integer` | 1-100, default 20 — above 100 is a 422, not clamped | Rows per page. |
| `q` | `string` | max 100 chars | Free-text search over the name. |
| `categoryId` | `integer` | min 1 | Narrow to one category. |
| `type` | `string` | account \| product | account (credentials are delivered) or product (a collaborator fulfils it by hand). |
| `priceMin` | `number` | 0-1000000 | Lower price bound. See the note below about what a price filter does to the result set. |
| `priceMax` | `number` | 0-1000000 | Upper price bound. |
| `hasWarranty` | `boolean` | — | Only groups that carry a warranty. |
| `attributes[key]` | `string` | up to 20 keys, key ≤ 64 chars, value ≤ 255 chars | Match a listing attribute, e.g. attributes[region]=EU. |
| `sort` | `string` | price \| warranty \| newest | Sort field. |
| `order` | `string` | asc \| desc | Sort direction. |



A **group** is one thing you can buy. `type` decides how it is delivered:

- `account` — stock is delivered automatically. `availableStock` says how many
  units are sellable right now, and credentials become readable once the order
  completes.
- `product` — a collaborator fulfils it by hand. The group holds individual
  products, and `sellingPrice` on the group itself is `0` because it does not
  apply.

`availableStock` is **omitted**, not null, on listings where it could not be
computed. Treat a missing key as unknown rather than as zero.

The listing hides an `account` group that has no sellable stock and does not
allow preorder, and a `product` group with no active product. Such a group still
answers `GET /client/catalog/groups/:id` — absent from the list is not the same
as gone.

Where a group asks the buyer questions at checkout, the payload carries a
`requireInput` array — one `{ key, label, type, required, … }` per question.
Those keys are what `inputs` on an order answers.

> **A price filter narrows the result set in a way that is easy to miss.** Send
> `priceMin`, `priceMax` or `sort=price` and the listing is restricted to
> `type="account"` groups. That is not a bug: a product group's `sellingPrice` is
> `0` meaning "not applicable", so including them would sort every one of them to
> the front and make the filter meaningless. If you want product groups, do not
> filter or sort by price.

Send no `sort` to get the operator's own ordering (`displayPriority`), which is
what the storefront shows.

**Example request**

```bash
curl -X GET 'https://rollroyce.store/api/v1/client/catalog/groups' \
  -H 'X-Api-Key: YOUR_API_KEY'
```

**Response `200`** — A page of groups.

```json
{
  "data": [
    {
      "id": 12,
      "categoryId": 3,
      "type": "account",
      "name": "Example listing",
      "description": "What the buyer receives.",
      "image": null,
      "sellingPrice": "12.5000",
      "attributes": {
        "region": "EU"
      },
      "allowPreorder": false,
      "hasWarranty": true,
      "warrantyMinutes": 1440,
      "minPurchaseQuantity": 1,
      "maxPurchaseQuantity": 10,
      "displayPriority": 0,
      "soldCount": 318,
      "createdAt": "2026-01-04T09:12:00.000Z",
      "availableStock": 74
    }
  ],
  "message": null,
  "meta": {
    "pagination": {
      "total": 42,
      "perPage": 20,
      "currentPage": 1,
      "lastPage": 3
    }
  }
}
```

---

### Get a group

```http
GET /api/v1/client/catalog/groups/:id
```

One listing group, with its live sellable stock count.

- **Auth**: `X-Api-Key` — a `customer` key (`ck_…`)
- **Ability**: `catalog:read`

**Path parameters**

| Field | Type | Constraint | Description |
|---|---|---|---|
| `id` **(required)** | `integer` | digits only | The group id. |



The same shape as one row of the listing, read fresh. Use it immediately before
placing an order: `availableStock`, `minPurchaseQuantity` and
`maxPurchaseQuantity` are the three values a checkout has to respect, and all
three can change between a listing being cached and an order being placed.

A group an operator has hidden or locked answers **404**, the same as one that
never existed — visibility is not something the API narrates.

**Example request**

```bash
curl -X GET 'https://rollroyce.store/api/v1/client/catalog/groups/12' \
  -H 'X-Api-Key: YOUR_API_KEY'
```

**Response `200`** — The group.

```json
{
  "data": {
    "id": 12,
    "categoryId": 3,
    "type": "account",
    "name": "Example listing",
    "description": "What the buyer receives.",
    "image": null,
    "sellingPrice": "12.5000",
    "attributes": {
      "region": "EU"
    },
    "allowPreorder": false,
    "hasWarranty": true,
    "warrantyMinutes": 1440,
    "minPurchaseQuantity": 1,
    "maxPurchaseQuantity": 10,
    "displayPriority": 0,
    "soldCount": 318,
    "createdAt": "2026-01-04T09:12:00.000Z",
    "availableStock": 74
  },
  "message": null
}
```

**Errors specific to this endpoint**

| Code | Status | Meaning | What to do |
|---|---|---|---|
| `E_GROUP_NOT_FOUND` | 404 | No listing group with that id, or it is not publicly visible. | Re-read the group list. An operator can hide or lock a group at any time. |

---

### List a group's products

```http
GET /api/v1/client/catalog/groups/:id/products
```

The individual products inside one product-type group.

- **Auth**: `X-Api-Key` — a `customer` key (`ck_…`)
- **Ability**: `catalog:read`
- **Paginated**: the envelope carries `meta.pagination`

**Path parameters**

| Field | Type | Constraint | Description |
|---|---|---|---|
| `id` **(required)** | `integer` | digits only | The group id. |

**Query parameters**

| Field | Type | Constraint | Description |
|---|---|---|---|
| `page` | `integer` | min 1, default 1 | 1-based page number. |
| `perPage` | `integer` | 1-100, default 20 — above 100 is a 422, not clamped | Rows per page. |



Only `type="product"` groups have rows here. Passing an `account` group's id
returns an **empty page**, not an error — the request was well-formed and the
answer is "none", which is a different thing from "wrong".

Each product's `sellingPrice` is per unit. An order for a product group names
products and quantities in `items`; the group's own price is not used.

**Example request**

```bash
curl -X GET 'https://rollroyce.store/api/v1/client/catalog/groups/12/products' \
  -H 'X-Api-Key: YOUR_API_KEY'
```

**Response `200`** — A page of products.

```json
{
  "data": [
    {
      "id": 77,
      "groupId": 12,
      "name": "Top-up 500",
      "image": null,
      "description": null,
      "sellingPrice": "4.9000",
      "displayPriority": 0,
      "createdAt": "2026-01-04T09:12:00.000Z"
    }
  ],
  "message": null,
  "meta": {
    "pagination": {
      "total": 42,
      "perPage": 20,
      "currentPage": 1,
      "lastPage": 3
    }
  }
}
```

**Errors specific to this endpoint**

| Code | Status | Meaning | What to do |
|---|---|---|---|
| `E_GROUP_NOT_FOUND` | 404 | No listing group with that id, or it is not publicly visible. | Re-read the group list. An operator can hide or lock a group at any time. |

---

### Quote an order

```http
POST /api/v1/client/orders/quote
```

Price a basket without creating anything. Nothing is reserved and nothing is charged.

- **Auth**: `X-Api-Key` — a `customer` key (`ck_…`)
- **Ability**: `order:quote`

**Request body**

| Field | Type | Constraint | Description |
|---|---|---|---|
| `orderType` **(required)** | `string` | account \| product | Which checkout branch this is. Explicit rather than inferred from the other fields. |
| `groupId` **(required)** | `integer` | min 1 | The group being bought from. |
| `quantity` | `integer` | min 1 | For orderType=account. Units to buy. A quote prices any quantity; the order enforces the group's bounds. |
| `preorder` | `boolean` | — | Accepted for symmetry with the order body; a quote ignores it. |
| `items` | `array` | 1-100 lines | For orderType=product. Each line is { productId, quantity }. |
| `inputs` | `object` | max 50 keys, 4000 chars per value | Answers to the questions the group asks at checkout (its requireInput list), as a flat string map. Ignored by a quote. |
| `code` | `string` | max 64 chars | A discount code. Accepted by the schema and then REFUSED with 422 on this surface — see the warning below. |



A quote **creates nothing**. No order, no reservation, no charge — it prices the
basket you describe and tells you what the account would pay. Stock is not held,
so a quote is a good faith estimate and not a promise.

The body is the same shape as `POST /client/orders`. Build one object, quote it,
then post it. A quote checks less than an order does: the group must exist,
be active and match `orderType`, a `product` basket must name at least one
line, and every product must belong to the group — but quantity bounds, stock,
product availability, `preorder` and `inputs` are only enforced when the
order is placed. A quote that succeeds is not an order that will.

Read `paidAmount`. It already has the account's standing discount applied, and
`discountSource` says where that discount came from:

| `discountSource` | Meaning |
|---|---|
| `none` | No discount applies |
| `user_manual` | A rate set on the account by an operator |
| `user_tier` | A rate earned by the account's spending tier |

> **Discount codes do not work through the API.** Sending `code` is a
> **422 `E_DISCOUNT_CODE_NOT_ALLOWED_VIA_API`**, and the order is not created.
> The refusal is deliberate and loud: silently ignoring the code would let a
> buyer pay full price believing they had a discount. Account-level discounts
> above still apply — you get those without asking. A quote that carries a
> `code` also spends a separate budget of 10 per minute per account before it
> is refused, so do not probe.

**Example request**

```bash
curl -X POST 'https://rollroyce.store/api/v1/client/orders/quote' \
  -H 'X-Api-Key: YOUR_API_KEY' \
  -H 'Origin: https://rollroyce.store' \
  -H 'Content-Type: application/json' \
  -d '{"orderType":"account","groupId":12,"quantity":2}'
```

**Response `200`** — What this basket would cost. No order exists.

```json
{
  "data": {
    "originalAmount": "25.0000",
    "discountAmount": "2.5000",
    "discountPercent": "10.00",
    "paidAmount": "22.5000",
    "discountSource": "user_tier"
  },
  "message": null
}
```

**Errors specific to this endpoint**

| Code | Status | Meaning | What to do |
|---|---|---|---|
| `E_DISCOUNT_CODE_NOT_ALLOWED_VIA_API` | 422 | A code field was sent to a quote or an order on the API surface. | Discount codes cannot be spent through the API at all. Account-level discounts still apply automatically — send no code. The refusal is loud rather than silent so a caller never pays full price believing a discount was applied. |
| `E_GROUP_UNAVAILABLE` | 409 | No purchasable group with that id: it does not exist, an operator has hidden or locked it, or its type does not match orderType. | Re-read the group. A missing group and an unavailable one answer the same code here, so do not treat this as temporary without checking. |
| `E_PRODUCT_NOT_FOUND` | 404 | A productId in items does not exist, or belongs to a different group. | Products belong to one group; check you are ordering from the group you listed. |

---

### Place an order

```http
POST /api/v1/client/orders
```

Creates the order and debits the wallet. This one spends real money.

- **Auth**: `X-Api-Key` — a `customer` key (`ck_…`)
- **Ability**: `order:create`
- **Changes state**: yes — this is not a safe request to retry blindly

**Request body**

| Field | Type | Constraint | Description |
|---|---|---|---|
| `orderType` **(required)** | `string` | account \| product | Same discriminator as the quote. |
| `groupId` **(required)** | `integer` | min 1 | The group being bought from. |
| `quantity` | `integer` | within the group's own min/max | For orderType=account. |
| `preorder` | `boolean` | — | Currently disabled. Accepted for compatibility but ignored: a quantity the shelf cannot cover is refused with 409 E_INSUFFICIENT_STOCK. |
| `items` | `array` | 1-100 lines | For orderType=product. { productId, quantity } per line. |
| `inputs` | `object` | max 50 keys, 4000 chars per value, 16 KiB in total | For orderType=product. Answers to the group's requireInput questions. A missing required answer is a 422 whose field is the question's key. |
| `code` | `string` | max 64 chars | Refused with 422 on this surface. |



This is the one endpoint that spends money. It debits the account's wallet and
creates a real order. There is no sandbox and no dry run — [quote it
first](#client.orders.quote).

### Two shapes, one discriminator

`orderType` is explicit rather than inferred from which fields you sent, so a
malformed request gets one clear error instead of a guess:

- `orderType: "account"` — set `quantity`. Optionally `preorder: true` on a group
  that allows buying ahead of stock.
- `orderType: "product"` — set `items`, up to 100 lines of
  `{ productId, quantity }`.

`inputs` answers a `product` group's own questions (its `requireInput` list).
Keys the group did not ask for are dropped silently; a required one that is
missing is a 422 whose `field` is the question's key. An `account` order
carries no `inputs`.

A group that is missing, hidden, locked, or of the other type is
**409 `E_GROUP_UNAVAILABLE`** — the same code for all four, so a 409 here does
not mean "try again later".

### 201 does not mean delivered

Every order comes back **201**, already paid for. Read `deliveryState` before
reading credentials: `done` means the credentials are written; `pending` means
a large order or a preorder is still being written in the background — poll
`GET /client/orders/:code` until it is `done`; `none` is a `product` order,
which is fulfilled by a collaborator and never has credentials; `failed` means
delivery could not complete and an operator will refund or retry.

> **There is no idempotency key.** If this request times out, do not resend it —
> read `GET /client/orders` and check whether the order landed. Resending blindly
> charges the wallet twice.

**Example request**

```bash
curl -X POST 'https://rollroyce.store/api/v1/client/orders' \
  -H 'X-Api-Key: YOUR_API_KEY' \
  -H 'Origin: https://rollroyce.store' \
  -H 'Content-Type: application/json' \
  -d '{"orderType":"account","groupId":12,"quantity":1}'
```

**Response `201`** — The order, already paid for. A large order is written in the background and comes back with deliveryState pending — poll GET /client/orders/:code until it is done.

```json
{
  "data": {
    "orderCode": "ord_9f2c1b7e-5a40-4c6d-9b21-8e0a4d3f5c11",
    "orderType": "account",
    "groupName": "Example listing",
    "items": null,
    "quantity": 2,
    "originalAmount": "25.0000",
    "discountAmount": "2.5000",
    "discountPercent": "10.00",
    "paidAmount": "22.5000",
    "discountSource": "user_tier",
    "status": "completed",
    "isRefunded": false,
    "refundAmount": "0.0000",
    "isPreorder": false,
    "deliveryState": "done",
    "deliveredCount": 2,
    "reservedCount": 0,
    "paidAt": "2026-02-11T14:03:22.000Z",
    "createdAt": "2026-02-11T14:03:20.000Z"
  },
  "message": "Purchase completed successfully"
}
```

**Errors specific to this endpoint**

| Code | Status | Meaning | What to do |
|---|---|---|---|
| `E_DISCOUNT_CODE_NOT_ALLOWED_VIA_API` | 422 | A code field was sent to a quote or an order on the API surface. | Discount codes cannot be spent through the API at all. Account-level discounts still apply automatically — send no code. The refusal is loud rather than silent so a caller never pays full price believing a discount was applied. |
| `E_GROUP_UNAVAILABLE` | 409 | No purchasable group with that id: it does not exist, an operator has hidden or locked it, or its type does not match orderType. | Re-read the group. A missing group and an unavailable one answer the same code here, so do not treat this as temporary without checking. |
| `E_INSUFFICIENT_STOCK` | 409 | Fewer units are sellable than were asked for. | Read availableStock on the group first. It is a snapshot, so a 409 here is normal under contention rather than a bug — retry with a smaller quantity. |
| `E_INSUFFICIENT_BALANCE` | 409 | The wallet does not hold what the order costs. | Top the wallet up. There is no credit and no partial fulfilment. |
| `E_PURCHASE_QUANTITY_RANGE` | 422 | quantity is outside the group's own minimum or maximum. | Both bounds ride on the group payload as minPurchaseQuantity and maxPurchaseQuantity. A quote does not check them — only the order does. |
| `E_PRODUCT_NOT_FOUND` | 404 | A productId in items does not exist, or belongs to a different group. | Products belong to one group; check you are ordering from the group you listed. |
| `E_PRODUCT_UNAVAILABLE` | 409 | A named product is not currently sellable. | Re-read the group's product list. A quote does not check this — only the order does. |
| `E_PURCHASE_AMOUNT_INDIVISIBLE` | 422 | After the discount, the amount paid cannot give every unit at least 0.0001. | Only reachable with a very large quantity of a very cheap listing. Order fewer units per request. |

---

### List your orders

```http
GET /api/v1/client/orders
```

Order history for the account the key acts as.

- **Auth**: `X-Api-Key` — a `customer` key (`ck_…`)
- **Ability**: `order:read`
- **Paginated**: the envelope carries `meta.pagination`

**Query parameters**

| Field | Type | Constraint | Description |
|---|---|---|---|
| `page` | `integer` | min 1, default 1 | 1-based page number. |
| `perPage` | `integer` | 1-100, default 20 — above 100 is a 422, not clamped | Rows per page. |



Every order the account has placed, newest first — including orders placed
through the website, not only through this API. A key reads its owner's history,
not its own.

`status` is the order's lifecycle (`pending`, `processing`, `completed`,
`cancelled`); `deliveryState` is whether the goods have been written yet
(`none`, `pending`, `done`, `failed`). They move independently, and for
anything that reads credentials the one that matters is `deliveryState`.

Because website orders are included, `discountSource` can also be `code` on an
order that was paid with a discount code there — a value an order placed through
the API can never carry.

**Example request**

```bash
curl -X GET 'https://rollroyce.store/api/v1/client/orders' \
  -H 'X-Api-Key: YOUR_API_KEY'
```

**Response `200`** — A page of orders.

```json
{
  "data": [
    {
      "orderCode": "ord_9f2c1b7e-5a40-4c6d-9b21-8e0a4d3f5c11",
      "orderType": "account",
      "groupName": "Example listing",
      "items": null,
      "quantity": 2,
      "originalAmount": "25.0000",
      "discountAmount": "2.5000",
      "discountPercent": "10.00",
      "paidAmount": "22.5000",
      "discountSource": "user_tier",
      "status": "completed",
      "isRefunded": false,
      "refundAmount": "0.0000",
      "isPreorder": false,
      "deliveryState": "done",
      "deliveredCount": 2,
      "reservedCount": 0,
      "paidAt": "2026-02-11T14:03:22.000Z",
      "createdAt": "2026-02-11T14:03:20.000Z"
    }
  ],
  "message": null,
  "meta": {
    "pagination": {
      "total": 42,
      "perPage": 20,
      "currentPage": 1,
      "lastPage": 3
    }
  }
}
```

---

### Get an order

```http
GET /api/v1/client/orders/:code
```

One order by its public code. Metadata only — no credentials.

- **Auth**: `X-Api-Key` — a `customer` key (`ck_…`)
- **Ability**: `order:read`

**Path parameters**

| Field | Type | Constraint | Description |
|---|---|---|---|
| `code` **(required)** | `string` | ^ord_[A-Za-z0-9-]+$ | The public order code. |



Orders are addressed by their public `ord_…` code, never by a numeric id. A value
that does not match that shape is refused by the router as a **404
`E_NOT_FOUND`** before any handler runs. An order that belongs to another
account is a **404 `E_ORDER_NOT_FOUND`** — a key never learns that a foreign
code exists.

The detail does **not** embed the delivered accounts. An order can hold tens of
thousands of them, so they live behind
[their own paginated endpoint](#client.orders.credentials).

`items` is the product-line snapshot for a `product` order, and `null` for an
`account` one. Cost prices are never included — you see what you were charged.

**Example request**

```bash
curl -X GET 'https://rollroyce.store/api/v1/client/orders/ord_9f2c1b7e-5a40-4c6d-9b21-8e0a4d3f5c11' \
  -H 'X-Api-Key: YOUR_API_KEY'
```

**Response `200`** — The order.

```json
{
  "data": {
    "orderCode": "ord_9f2c1b7e-5a40-4c6d-9b21-8e0a4d3f5c11",
    "orderType": "account",
    "groupName": "Example listing",
    "items": null,
    "quantity": 2,
    "originalAmount": "25.0000",
    "discountAmount": "2.5000",
    "discountPercent": "10.00",
    "paidAmount": "22.5000",
    "discountSource": "user_tier",
    "status": "completed",
    "isRefunded": false,
    "refundAmount": "0.0000",
    "isPreorder": false,
    "deliveryState": "done",
    "deliveredCount": 2,
    "reservedCount": 0,
    "paidAt": "2026-02-11T14:03:22.000Z",
    "createdAt": "2026-02-11T14:03:20.000Z"
  },
  "message": null
}
```

**Errors specific to this endpoint**

| Code | Status | Meaning | What to do |
|---|---|---|---|
| `E_ORDER_NOT_FOUND` | 404 | No order with that code on this account. On the credentials and export routes, also: the order is not an account order, or its deliveryState is not done yet. | An order that belongs to another account answers this same 404 — a key never learns that a foreign code exists. On the credentials and export routes, poll deliveryState on the order first: a preorder or a large order is written after the 201 and reaches done later. |

---

### Read delivered credentials

```http
GET /api/v1/client/orders/:code/credentials
```

The decrypted accounts of your own completed account-type order.

- **Auth**: `X-Api-Key` — a `customer` key (`ck_…`)
- **Ability**: `credential:read`
- **Paginated**: the envelope carries `meta.pagination`
- **Extra rate limit**: 10 requests per minute, per account — shared with the website's own credential views
- **Extra rate limit**: 120,000 credential rows per hour, per account

**Path parameters**

| Field | Type | Constraint | Description |
|---|---|---|---|
| `code` **(required)** | `string` | ^ord_[A-Za-z0-9-]+$ | The public order code. |

**Query parameters**

| Field | Type | Constraint | Description |
|---|---|---|---|
| `page` | `integer` | min 1, default 1 | 1-based page number. |
| `perPage` | `integer` | 1-100, default 20 — above 100 is a 422, not clamped | Rows per page. |



**The only endpoint in this API that decrypts anything.** It returns the account
credentials delivered by a completed `account` order, and it is the reason
`credential:read` is a separate ability from `order:read`.

It is paginated because an order can carry tens of thousands of rows, and it
carries two limits rather than one: 10 requests per minute, and 120,000
credential **rows** per hour. Both are counted per account rather than per key,
and the first is shared with the website's own credential views. The second is
what stops a compromised key from draining an entire order history in one pass.

`warrantyExpiresAt` is `null` on a group with no warranty. Where it is set, it is
the deadline for raising a complaint about that specific account.

> Fetch these once and store them where you keep secrets. Re-reading the same
> page repeatedly spends a budget that exists to make bulk extraction slow, and
> the data does not change. Reading a page also marks those rows as viewed on
> the website.

An order still being written — or a `product` order, which never has
credentials — answers **404 `E_ORDER_NOT_FOUND`**, the same as an order that
does not exist. Poll the order's `deliveryState` first.

**Example request**

```bash
curl -X GET 'https://rollroyce.store/api/v1/client/orders/ord_9f2c1b7e-5a40-4c6d-9b21-8e0a4d3f5c11/credentials' \
  -H 'X-Api-Key: YOUR_API_KEY'
```

**Response `200`** — A page of credentials. Reading a page also marks those rows as viewed on the website.

```json
{
  "data": [
    {
      "id": 90211,
      "username": "delivered-account",
      "password": "delivered-secret",
      "extra": null,
      "warrantyExpiresAt": "2026-02-12T14:03:22.000Z"
    }
  ],
  "message": "Account credentials revealed",
  "meta": {
    "pagination": {
      "total": 42,
      "perPage": 20,
      "currentPage": 1,
      "lastPage": 3
    }
  }
}
```

**Errors specific to this endpoint**

| Code | Status | Meaning | What to do |
|---|---|---|---|
| `E_ORDER_NOT_FOUND` | 404 | No order with that code on this account. On the credentials and export routes, also: the order is not an account order, or its deliveryState is not done yet. | An order that belongs to another account answers this same 404 — a key never learns that a foreign code exists. On the credentials and export routes, poll deliveryState on the order first: a preorder or a large order is written after the 201 and reaches done later. |

---

### Download delivered credentials

```http
GET /api/v1/client/orders/:code/export
```

The whole order in one streamed response: a text file, one account per line, or NDJSON.

- **Auth**: `X-Api-Key` — a `customer` key (`ck_…`)
- **Ability**: `credential:read`
- **Extra rate limit**: 10 requests per minute, per account — shared with the website's own credential views; one download is one request
- **Extra rate limit**: 120,000 credential rows per hour, per account — the whole order counts against it

**Path parameters**

| Field | Type | Constraint | Description |
|---|---|---|---|
| `code` **(required)** | `string` | ^ord_[A-Za-z0-9-]+$ | The public order code. |

**Query parameters**

| Field | Type | Constraint | Description |
|---|---|---|---|
| `format` | `string` | txt \| ndjson | txt (default) is the text file described below. ndjson is one JSON object per line — { id, username, password, extra, warrantyExpiresAt }, the same row as the paginated read — for a program that wants the whole order in one request without parsing a delimited file. |
| `delimiter` | `string` | : \| / \| \| \| , | txt only. What separates the three fields on each line. Fields are written as they are, never quoted — pick one your data does not contain. |



The same credentials as the paginated read, delivered as **one file in one
request**: `text/plain`, sent as an attachment named `<code>-accounts.txt`,
one account per line as `username<delimiter>password<delimiter>extra`. There
are always three fields on a line — an account with no `extra` ends in an
empty field — lines are separated by CRLF, and the file ends on its last line.

It exists because the paginated read is throttled by **request**: at 100 rows a
page and 10 requests a minute, an order over 1,000 accounts cannot be read in
one go that way. A download is one request whatever the order size, and the
server streams it a batch at a time rather than building the file in memory.

`delimiter` picks the separator (`:`, `/`, `|` or `,`, default `:`). Fields are
written exactly as delivered, never quoted or escaped, so choose one your data
cannot contain.

`format=ndjson` streams the same rows as one JSON object per line instead —
`{ id, username, password, extra, warrantyExpiresAt }`, the row the paginated
read returns — for a program that wants the whole order in one request without
parsing a delimited file. It is sent inline rather than as an attachment.

> The whole order counts against the 120,000-rows-per-hour budget the moment
> the download starts, and every row in the file is marked as viewed on the
> website — a buyer who has the file has every credential in it. Download once
> and store the file where you keep secrets.

An order still being written — or a `product` order, which never has
credentials — answers **404 `E_ORDER_NOT_FOUND`**, the same as an order that
does not exist. Poll the order's `deliveryState` first. Every refusal, including
a 429, is decided before the first byte, so it arrives as the usual JSON error
envelope and never as a truncated file.

**Response `200`** — txt: text/plain; charset=utf-8, sent as an attachment named <code>-accounts.txt. One line per account, username<delimiter>password<delimiter>extra — always three fields, an absent extra is an empty field, lines end with CRLF, no trailing line break. ndjson: application/x-ndjson; charset=utf-8, inline, one record per line, each ending with a line feed. Either way, every row streamed is marked as viewed on the website.

**Errors specific to this endpoint**

| Code | Status | Meaning | What to do |
|---|---|---|---|
| `E_ORDER_NOT_FOUND` | 404 | No order with that code on this account. On the credentials and export routes, also: the order is not an account order, or its deliveryState is not done yet. | An order that belongs to another account answers this same 404 — a key never learns that a foreign code exists. On the credentials and export routes, poll deliveryState on the order first: a preorder or a large order is written after the 201 and reaches done later. |

---

### Get wallet balance

```http
GET /api/v1/client/me/balance
```

The wallet balance and the discount rate the account is currently getting.

- **Auth**: `X-Api-Key` — a `customer` key (`ck_…`)
- **Ability**: `balance:read`


There is no top-up endpoint on this surface. Funding a wallet happens on the
website, and deliberately so: it moves real money and belongs behind a session.

Check this before a batch of orders rather than discovering
`409 E_INSUFFICIENT_BALANCE` partway through one. `discountPercent` here is the
same rate a quote would apply, computed without a basket.

**Example request**

```bash
curl -X GET 'https://rollroyce.store/api/v1/client/me/balance' \
  -H 'X-Api-Key: YOUR_API_KEY'
```

**Response `200`** — The balance and standing discount.

```json
{
  "data": {
    "balance": "184.2500",
    "discountPercent": "10.00",
    "discountSource": "user_tier"
  },
  "message": null
}
```

---

### The claim queue

```http
GET /api/v1/collab/product-orders/queue
```

Unclaimed product orders waiting for a collaborator.

- **Auth**: `X-Api-Key` — a `collaborator` key (`lk_…`)
- **Ability**: `product_order:read`
- **Paginated**: the envelope carries `meta.pagination`

**Query parameters**

| Field | Type | Constraint | Description |
|---|---|---|---|
| `page` | `integer` | min 1, default 1 | 1-based page number. |
| `perPage` | `integer` | 1-100, default 20 — above 100 is a 422, not clamped | Rows per page. |



Product orders waiting for someone to take them. This is the work board.

What you see here is deliberately thinner than what you see once you hold an
order: **no `payoutAmount`, and no prices on the items.** An unclaimed order
shows what the work *is*, not what it pays — so the queue cannot be mined for the
platform's margins by anyone who never intends to claim anything.

Poll it. The collaborator budget is 240 requests a minute for exactly this
reason, so a check every few seconds is well within it.

**Example request**

```bash
curl -X GET 'https://rollroyce.store/api/v1/collab/product-orders/queue' \
  -H 'X-Api-Key: YOUR_API_KEY'
```

**Response `200`** — A page of unclaimed orders.

```json
{
  "data": [
    {
      "id": 5512,
      "orderId": 8821,
      "state": "unclaimed",
      "handlerUserId": null,
      "claimedAt": null,
      "completedAt": null,
      "resultNote": null,
      "payoutReleaseAt": null,
      "payoutPaid": false,
      "payoutPaidAt": null,
      "createdAt": "2026-02-11T14:03:22.000Z",
      "order": {
        "orderCode": "ord_9f2c1b7e-5a40-4c6d-9b21-8e0a4d3f5c11",
        "items": [
          {
            "product_id": 77,
            "item": "Top-up 500",
            "quantity": 1
          }
        ]
      }
    }
  ],
  "message": null,
  "meta": {
    "pagination": {
      "total": 42,
      "perPage": 20,
      "currentPage": 1,
      "lastPage": 3
    }
  }
}
```

**Errors specific to this endpoint**

| Code | Status | Meaning | What to do |
|---|---|---|---|
| `E_COLLABORATOR_NOT_APPROVED` | 403 | The account behind the key is not an approved collaborator. | Checked live on every request and never cached, so a partnership that is withdrawn stops working on the very next call. |

---

### Your product orders

```http
GET /api/v1/collab/product-orders
```

Orders this collaborator has claimed, in any state.

- **Auth**: `X-Api-Key` — a `collaborator` key (`lk_…`)
- **Ability**: `product_order:read`
- **Paginated**: the envelope carries `meta.pagination`

**Query parameters**

| Field | Type | Constraint | Description |
|---|---|---|---|
| `page` | `integer` | min 1, default 1 | 1-based page number. |
| `perPage` | `integer` | 1-100, default 20 — above 100 is a 422, not clamped | Rows per page. |
| `state` | `string` | unclaimed \| claimed \| completed \| cancelled | Filter by lifecycle state. |



Orders you currently hold or have completed. `payoutAmount` is present here,
because you hold them.

`state` filters the list. The four values are the whole lifecycle, but only two
of them can appear here:

| `state` | Meaning |
|---|---|
| `claimed` | You hold it and it is not finished |
| `completed` | You reported it done; the payout clock is running, or it has been paid |
| `unclaimed` | In the queue, nobody holds it — never yours, so never listed here |
| `cancelled` | Given up and refunded. Cancelling releases the order, so it leaves this list |

Money still in escrow is a `completed` row with `payoutPaidAt` null; this is
where to look for it, not [the payout history](#collab.payouts.list).

There is no ceiling on how many orders you may hold at once. The claim rate limit
is about how fast you take them, not how many you have.

**Example request**

```bash
curl -X GET 'https://rollroyce.store/api/v1/collab/product-orders' \
  -H 'X-Api-Key: YOUR_API_KEY'
```

**Response `200`** — A page of your orders.

```json
{
  "data": [
    {
      "id": 5512,
      "orderId": 8821,
      "state": "claimed",
      "handlerUserId": 41,
      "claimedAt": "2026-02-11T14:20:00.000Z",
      "completedAt": null,
      "resultNote": null,
      "payoutAmount": "3.7500",
      "payoutReleaseAt": null,
      "payoutPaid": false,
      "payoutPaidAt": null,
      "createdAt": "2026-02-11T14:03:22.000Z",
      "order": {
        "orderCode": "ord_9f2c1b7e-5a40-4c6d-9b21-8e0a4d3f5c11",
        "items": [
          {
            "product_id": 77,
            "item": "Top-up 500",
            "quantity": 1
          }
        ]
      }
    }
  ],
  "message": null,
  "meta": {
    "pagination": {
      "total": 42,
      "perPage": 20,
      "currentPage": 1,
      "lastPage": 3
    }
  }
}
```

**Errors specific to this endpoint**

| Code | Status | Meaning | What to do |
|---|---|---|---|
| `E_COLLABORATOR_NOT_APPROVED` | 403 | The account behind the key is not an approved collaborator. | Checked live on every request and never cached, so a partnership that is withdrawn stops working on the very next call. |

---

### Get a product order

```http
GET /api/v1/collab/product-orders/:id
```

One order in full, including the buyer's answers to the group's questions.

- **Auth**: `X-Api-Key` — a `collaborator` key (`lk_…`)
- **Ability**: `product_order:read`

**Path parameters**

| Field | Type | Constraint | Description |
|---|---|---|---|
| `id` **(required)** | `integer` | digits only | The product order id. |



One order in full — but only for the order you **hold**. Any other order,
including one still in the queue, is a **403 `E_PRODUCT_ORDER_INPUT_FORBIDDEN`**;
an id that does not exist is a 404.

That means the buyer's brief is not readable before claiming. The queue shows
what the work *is* (the items); `input` — `schema` for what was asked, `values`
for what the buyer replied — is only here, after the claim. Claim what the item
list tells you that you can do, then read the brief.

As the holder you get `payoutAmount` and each item's `selling_price` and
`cost_price`. The response is the same shape when the buyer's own account reads
it, with `payoutAmount` and `handlerUserId` null and the prices stripped —
write your client to tolerate `null` there.

**Example request**

```bash
curl -X GET 'https://rollroyce.store/api/v1/collab/product-orders/5512' \
  -H 'X-Api-Key: YOUR_API_KEY'
```

**Response `200`** — The order.

```json
{
  "data": {
    "id": 5512,
    "orderId": 8821,
    "state": "claimed",
    "handlerUserId": 41,
    "claimedAt": "2026-02-11T14:20:00.000Z",
    "completedAt": null,
    "resultNote": null,
    "payoutAmount": "3.7500",
    "payoutReleaseAt": null,
    "payoutPaid": false,
    "payoutPaidAt": null,
    "createdAt": "2026-02-11T14:03:22.000Z",
    "order": {
      "orderCode": "ord_9f2c1b7e-5a40-4c6d-9b21-8e0a4d3f5c11",
      "items": [
        {
          "product_id": 77,
          "item": "Top-up 500",
          "quantity": 1
        }
      ]
    },
    "input": {
      "schema": [
        {
          "key": "server",
          "label": "Server"
        }
      ],
      "values": {
        "server": "eu-west"
      }
    }
  },
  "message": null
}
```

**Errors specific to this endpoint**

| Code | Status | Meaning | What to do |
|---|---|---|---|
| `E_COLLABORATOR_NOT_APPROVED` | 403 | The account behind the key is not an approved collaborator. | Checked live on every request and never cached, so a partnership that is withdrawn stops working on the very next call. |
| `E_PRODUCT_ORDER_NOT_FOUND` | 404 | No product order with that id is visible to this collaborator. | Re-read the queue. |
| `E_PRODUCT_ORDER_INPUT_FORBIDDEN` | 403 | The order exists, but you do not hold it. | Only the current holder (or the buyer) may read an order's detail. An unclaimed order cannot be read here — claim it first, or work from the queue listing. |

---

### Claim an order

```http
POST /api/v1/collab/product-orders/:id/claim
```

Take an order out of the queue. Once claimed it is yours to finish.

- **Auth**: `X-Api-Key` — a `collaborator` key (`lk_…`)
- **Ability**: `product_order:claim`
- **Changes state**: yes — this is not a safe request to retry blindly
- **Extra rate limit**: 20 claims per minute, per account — shared by every key you hold and by the website; a LOST race still spends one

**Path parameters**

| Field | Type | Constraint | Description |
|---|---|---|---|
| `id` **(required)** | `integer` | digits only | The product order id. |



Takes an order out of the queue and assigns it to you. Once claimed it is yours:
**there is no way to hand it back.** The only exits are
[complete](#collab.productOrders.complete) and
[cancel](#collab.productOrders.cancel), and cancelling refunds the buyer.

The claim is a single atomic write, so exactly one collaborator wins a contested
order and everyone else gets **409 `E_PRODUCT_ORDER_ALREADY_CLAIMED`**. That is
normal traffic, not an error to alert on.

> **A lost race still spends one of your 20 claims per minute.** The budget is
> charged before the handler runs. Claim orders you have read and intend to work
> on; a bot that claims the whole queue on sight burns its budget losing races.

**Example request**

```bash
curl -X POST 'https://rollroyce.store/api/v1/collab/product-orders/5512/claim' \
  -H 'X-Api-Key: YOUR_API_KEY' \
  -H 'Origin: https://rollroyce.store'
```

**Response `200`** — You hold it now.

```json
{
  "data": {
    "id": 5512,
    "orderId": 8821,
    "state": "claimed",
    "handlerUserId": 41,
    "claimedAt": "2026-02-11T14:20:00.000Z",
    "completedAt": null,
    "resultNote": null,
    "payoutAmount": "3.7500",
    "payoutReleaseAt": null,
    "payoutPaid": false,
    "payoutPaidAt": null,
    "createdAt": "2026-02-11T14:03:22.000Z",
    "order": {
      "orderCode": "ord_9f2c1b7e-5a40-4c6d-9b21-8e0a4d3f5c11",
      "items": [
        {
          "product_id": 77,
          "item": "Top-up 500",
          "quantity": 1
        }
      ]
    }
  },
  "message": null
}
```

**Errors specific to this endpoint**

| Code | Status | Meaning | What to do |
|---|---|---|---|
| `E_COLLABORATOR_NOT_APPROVED` | 403 | The account behind the key is not an approved collaborator. | Checked live on every request and never cached, so a partnership that is withdrawn stops working on the very next call. |
| `E_PRODUCT_ORDER_NOT_FOUND` | 404 | No product order with that id is visible to this collaborator. | Re-read the queue. |
| `E_PRODUCT_ORDER_ALREADY_CLAIMED` | 409 | The order is no longer unclaimed — another collaborator took it first, or it is already completed or cancelled. | Expected, not exceptional — the queue is contended and the claim is one atomic write. Take the next one. Note that a lost race still spends a claim from your budget. |

---

### Complete an order

```http
POST /api/v1/collab/product-orders/:id/complete
```

Report the work done. Starts the payout clock.

- **Auth**: `X-Api-Key` — a `collaborator` key (`lk_…`)
- **Ability**: `product_order:complete`
- **Changes state**: yes — this is not a safe request to retry blindly

**Path parameters**

| Field | Type | Constraint | Description |
|---|---|---|---|
| `id` **(required)** | `integer` | digits only | The product order id. |

**Request body**

| Field | Type | Constraint | Description |
|---|---|---|---|
| `resultNote` | `string` | max 5000 chars, nullable | What you delivered. Reaches the buyer. |



Reports the work done. This starts the payout clock: `payoutReleaseAt` comes back
set — the completion time plus the operator's hold, 24 hours by default — and
the money leaves escrow at that time rather than immediately.

`resultNote` reaches the buyer. It is how you tell them what you delivered and
where to find it, so it is worth writing properly — for many orders it is the
entire delivery.

Only the holder may complete. Completing twice is
**409 `E_PRODUCT_ORDER_STATE_INVALID`**, not a second payout.

**Example request**

```bash
curl -X POST 'https://rollroyce.store/api/v1/collab/product-orders/5512/complete' \
  -H 'X-Api-Key: YOUR_API_KEY' \
  -H 'Origin: https://rollroyce.store' \
  -H 'Content-Type: application/json' \
  -d '{"resultNote":"Delivered, code sent in-app."}'
```

**Response `200`** — Completed. payoutReleaseAt is when the money leaves escrow.

```json
{
  "data": {
    "id": 5512,
    "orderId": 8821,
    "state": "completed",
    "handlerUserId": 41,
    "claimedAt": "2026-02-11T14:20:00.000Z",
    "completedAt": "2026-02-11T15:02:00.000Z",
    "resultNote": "Delivered, code sent in-app.",
    "payoutAmount": "3.7500",
    "payoutReleaseAt": "2026-02-12T15:02:00.000Z",
    "payoutPaid": false,
    "payoutPaidAt": null,
    "createdAt": "2026-02-11T14:03:22.000Z",
    "order": {
      "orderCode": "ord_9f2c1b7e-5a40-4c6d-9b21-8e0a4d3f5c11",
      "items": [
        {
          "product_id": 77,
          "item": "Top-up 500",
          "quantity": 1
        }
      ]
    }
  },
  "message": null
}
```

**Errors specific to this endpoint**

| Code | Status | Meaning | What to do |
|---|---|---|---|
| `E_COLLABORATOR_NOT_APPROVED` | 403 | The account behind the key is not an approved collaborator. | Checked live on every request and never cached, so a partnership that is withdrawn stops working on the very next call. |
| `E_PRODUCT_ORDER_NOT_FOUND` | 404 | No product order with that id is visible to this collaborator. | Re-read the queue. |
| `E_PRODUCT_ORDER_NOT_HANDLER` | 403 | The order is held by a different collaborator. | Only the holder may complete or cancel. |
| `E_PRODUCT_ORDER_STATE_INVALID` | 409 | The order is not in a state this action accepts. | A completed order cannot be completed twice and a cancelled one cannot be revived. Read state before acting. |

---

### Cancel an order

```http
POST /api/v1/collab/product-orders/:id/cancel
```

Give up an order you are holding. The buyer is refunded in full.

- **Auth**: `X-Api-Key` — a `collaborator` key (`lk_…`)
- **Ability**: `product_order:cancel`
- **Changes state**: yes — this is not a safe request to retry blindly
- **Extra rate limit**: 10 cancellations per minute, per account — shared by every key you hold and by the website; tighter than every other route on this surface

**Path parameters**

| Field | Type | Constraint | Description |
|---|---|---|---|
| `id` **(required)** | `integer` | digits only | The product order id. |

**Request body**

| Field | Type | Constraint | Description |
|---|---|---|---|
| `reason` | `string` | max 500 chars, nullable | Why. Recorded on the order. |



Gives up an order you are holding. The buyer is refunded **in full**, and the
payout for it will never be released. The order is released too — it leaves
[your list](#collab.productOrders.mine), and this response is the last time
you can read it.

> **This is the most dangerous call in the API.** It destroys a real order and
> moves real money, and it is why `product_order:cancel` is the one ability left
> out of the default grant — a key must ask for it explicitly. Mint that key
> narrowly, and keep it out of anything that runs unattended.

It carries a tighter budget than every other route on this surface: 10 a minute.
That is not a throughput limit, it is a blast radius.

`reason` is recorded on the order. Fill it in — it is what an operator reads when
a buyer asks what happened.

**Example request**

```bash
curl -X POST 'https://rollroyce.store/api/v1/collab/product-orders/5512/cancel' \
  -H 'X-Api-Key: YOUR_API_KEY' \
  -H 'Origin: https://rollroyce.store' \
  -H 'Content-Type: application/json' \
  -d '{"reason":"Cannot source this variant."}'
```

**Response `200`** — Cancelled and refunded.

```json
{
  "data": {
    "id": 5512,
    "orderId": 8821,
    "state": "cancelled",
    "handlerUserId": null,
    "claimedAt": null,
    "completedAt": null,
    "resultNote": null,
    "payoutAmount": "3.7500",
    "payoutReleaseAt": null,
    "payoutPaid": false,
    "payoutPaidAt": null,
    "createdAt": "2026-02-11T14:03:22.000Z",
    "order": {
      "orderCode": "ord_9f2c1b7e-5a40-4c6d-9b21-8e0a4d3f5c11",
      "items": [
        {
          "product_id": 77,
          "item": "Top-up 500",
          "quantity": 1
        }
      ]
    }
  },
  "message": null
}
```

**Errors specific to this endpoint**

| Code | Status | Meaning | What to do |
|---|---|---|---|
| `E_COLLABORATOR_NOT_APPROVED` | 403 | The account behind the key is not an approved collaborator. | Checked live on every request and never cached, so a partnership that is withdrawn stops working on the very next call. |
| `E_PRODUCT_ORDER_NOT_FOUND` | 404 | No product order with that id is visible to this collaborator. | Re-read the queue. |
| `E_PRODUCT_ORDER_NOT_HANDLER` | 403 | The order is held by a different collaborator. | Only the holder may complete or cancel. |
| `E_PRODUCT_ORDER_STATE_INVALID` | 409 | The order is not in a state this action accepts. | A completed order cannot be completed twice and a cancelled one cannot be revived. Read state before acting. |

---

### Payout history

```http
GET /api/v1/collab/payouts
```

Payouts already released to you. Money still in escrow is on your product orders, not here.

- **Auth**: `X-Api-Key` — a `collaborator` key (`lk_…`)
- **Ability**: `payout:read`
- **Paginated**: the envelope carries `meta.pagination`

**Query parameters**

| Field | Type | Constraint | Description |
|---|---|---|---|
| `page` | `integer` | min 1, default 1 | 1-based page number. |
| `perPage` | `integer` | 1-100, default 20 — above 100 is a 422, not clamped | Rows per page. |



What has already been paid to you, most recently paid first. Every row here has
`payoutPaidAt` set; money still in escrow is **not** listed — it is a
`completed` row on [your product orders](#collab.productOrders.mine), where:

- `payoutReleaseAt` in the future, `payoutPaidAt` null — done and still in
  escrow.
- `payoutReleaseAt` in the past, `payoutPaidAt` null — due, waiting on the payout
  run.

Escrow is a hold, not a review: nobody has to approve anything for the clock to
run out. It exists so a complaint raised shortly after delivery can still be
settled against money that has not moved yet.

**Example request**

```bash
curl -X GET 'https://rollroyce.store/api/v1/collab/payouts' \
  -H 'X-Api-Key: YOUR_API_KEY'
```

**Response `200`** — A page of paid-out orders, most recently paid first. Only rows with payoutPaidAt set appear.

```json
{
  "data": [
    {
      "id": 5512,
      "payoutAmount": "3.7500",
      "completedAt": "2026-02-11T15:02:00.000Z",
      "payoutReleaseAt": "2026-02-12T15:02:00.000Z",
      "payoutPaidAt": "2026-02-12T16:00:00.000Z"
    }
  ],
  "message": null,
  "meta": {
    "pagination": {
      "total": 42,
      "perPage": 20,
      "currentPage": 1,
      "lastPage": 3
    }
  }
}
```

**Errors specific to this endpoint**

| Code | Status | Meaning | What to do |
|---|---|---|---|
| `E_COLLABORATOR_NOT_APPROVED` | 403 | The account behind the key is not an approved collaborator. | Checked live on every request and never cached, so a partnership that is withdrawn stops working on the very next call. |

---

### Complaints against you

```http
GET /api/v1/collab/complaints
```

Complaint items filed against your sales — the ones waiting on you and the ones already answered.

- **Auth**: `X-Api-Key` — a `collaborator` key (`lk_…`)
- **Ability**: `complaint:read`
- **Paginated**: the envelope carries `meta.pagination`

**Query parameters**

| Field | Type | Constraint | Description |
|---|---|---|---|
| `page` | `integer` | min 1, default 1 | 1-based page number. |
| `perPage` | `integer` | 1-100, default 20 — above 100 is a 422, not clamped | Rows per page. |
| `status` | `string` | open \| fixed \| escalated \| refunded \| rejected \| done | Only items in this status. |



Every complaint item filed against one of your sales — an account you supplied
or a product order you completed. One row per disputed **item**, not per
complaint: a buyer who names ten accounts gives you ten rows, each answered on
its own.

The two statuses that need you:

- `open` — the buyer just filed it. Answer before `collabRespondBy`, or the
  buyer is refunded automatically and, if the money had reached you, clawed
  back.
- `fixed` — you sent a fix and the buyer has until `buyerConfirmBy` to say
  it worked. Their silence closes the item in your favour.

`escalated` means the buyer said your fix did not work and an administrator
decides. The three closed statuses (`refunded`, `rejected`, `done`) carry
`closedReason` — which road led there.

`amountAtStake` is what a refund would cost you: the account's price, or the
product order's remaining paid amount. `alreadyPaidOut` tells you whether that
money is still in escrow (a refund just cancels the payout) or already in your
pot (a refund is clawed back — pot, then collateral, then debt).

**Example request**

```bash
curl -X GET 'https://rollroyce.store/api/v1/collab/complaints' \
  -H 'X-Api-Key: YOUR_API_KEY'
```

**Response `200`** — A page of your items, newest first. reason is the one that applies to THIS item; amountAtStake is what a refund would cost you.

```json
{
  "data": [
    {
      "id": 301,
      "complaintCode": "cmp_4c2a9f10-7b3e-4d88-9e61-2f5b8c7d1a90",
      "orderCode": "ord_9f2c1b7e-5a40-4c6d-9b21-8e0a4d3f5c11",
      "subjectType": "order_item",
      "orderItemId": 12044,
      "reason": "Password no longer works.",
      "attachments": [
        "/uploads/complaint/x8Fq2mZk0aLp.webp"
      ],
      "status": "open",
      "closedReason": null,
      "amountAtStake": "12.5000",
      "alreadyPaidOut": false,
      "collabRespondBy": "2026-02-13T14:20:00.000Z",
      "collabRespondedAt": null,
      "collabAction": null,
      "collabNote": null,
      "buyerConfirmBy": null,
      "buyerConfirmedAt": null,
      "buyerVerdict": null,
      "buyerNote": null,
      "refundAmount": "0.0000",
      "clawbackAmount": "0.0000",
      "resolution": null,
      "resolvedAt": null,
      "createdAt": "2026-02-11T14:20:00.000Z"
    }
  ],
  "message": null,
  "meta": {
    "pagination": {
      "total": 42,
      "perPage": 20,
      "currentPage": 1,
      "lastPage": 3
    }
  }
}
```

---

### One complaint item

```http
GET /api/v1/collab/complaints/:id
```

One item of yours. Somebody else's item is a 404, never a 403.

- **Auth**: `X-Api-Key` — a `collaborator` key (`lk_…`)
- **Ability**: `complaint:read`

**Path parameters**

| Field | Type | Constraint | Description |
|---|---|---|---|
| `id` **(required)** | `integer` | digits only | The complaint item id. |



One item, by id. An item that is not yours is a **404**, not a 403 — whether a
complaint exists against somebody else's sale is not yours to learn.

**Example request**

```bash
curl -X GET 'https://rollroyce.store/api/v1/collab/complaints/301' \
  -H 'X-Api-Key: YOUR_API_KEY'
```

**Response `200`** — The item.

```json
{
  "data": {
    "id": 301,
    "complaintCode": "cmp_4c2a9f10-7b3e-4d88-9e61-2f5b8c7d1a90",
    "orderCode": "ord_9f2c1b7e-5a40-4c6d-9b21-8e0a4d3f5c11",
    "subjectType": "order_item",
    "orderItemId": 12044,
    "reason": "Password no longer works.",
    "attachments": [
      "/uploads/complaint/x8Fq2mZk0aLp.webp"
    ],
    "status": "open",
    "closedReason": null,
    "amountAtStake": "12.5000",
    "alreadyPaidOut": false,
    "collabRespondBy": "2026-02-13T14:20:00.000Z",
    "collabRespondedAt": null,
    "collabAction": null,
    "collabNote": null,
    "buyerConfirmBy": null,
    "buyerConfirmedAt": null,
    "buyerVerdict": null,
    "buyerNote": null,
    "refundAmount": "0.0000",
    "clawbackAmount": "0.0000",
    "resolution": null,
    "resolvedAt": null,
    "createdAt": "2026-02-11T14:20:00.000Z"
  },
  "message": null
}
```

---

### Refund the buyer

```http
POST /api/v1/collab/complaints/:id/refund
```

Close the item in the buyer's favour. If the money had already been paid out to you, it is clawed back.

- **Auth**: `X-Api-Key` — a `collaborator` key (`lk_…`)
- **Ability**: `complaint:respond`
- **Changes state**: yes — this is not a safe request to retry blindly

**Path parameters**

| Field | Type | Constraint | Description |
|---|---|---|---|
| `id` **(required)** | `integer` | digits only | The complaint item id. |

**Request body**

| Field | Type | Constraint | Description |
|---|---|---|---|
| `note` | `string` | max 2000 chars, nullable | A word to the buyer. Recorded on the item. |



Close the item in the buyer's favour. This is final: the buyer's wallet is
credited the full `amountAtStake` in the same transaction, and the item can
never be reopened from this surface.

**Where the money comes from** depends on `alreadyPaidOut`:

- `false` — the payout was still in escrow. It is simply never released.
- `true` — you had been paid. The whole refund is recovered down the ladder:
  your commission pot first, your collateral deposit second, and the remainder
  as a negative pot balance (a recorded debt). Your spendable wallet is never
  touched.

Only an `open` item can be refunded from here; anything else is a
`422 E_COMPLAINT_NOT_ACTIONABLE`.

**Example request**

```bash
curl -X POST 'https://rollroyce.store/api/v1/collab/complaints/301/refund' \
  -H 'X-Api-Key: YOUR_API_KEY' \
  -H 'Origin: https://rollroyce.store' \
  -H 'Content-Type: application/json' \
  -d '{"note":"Sorry — refunded."}'
```

**Response `200`** — Refunded and closed. refundAmount is what the buyer got back; clawbackAmount is what was taken from you (0 when the payout had not reached you yet).

```json
{
  "data": {
    "id": 301,
    "complaintCode": "cmp_4c2a9f10-7b3e-4d88-9e61-2f5b8c7d1a90",
    "orderCode": "ord_9f2c1b7e-5a40-4c6d-9b21-8e0a4d3f5c11",
    "subjectType": "order_item",
    "orderItemId": 12044,
    "reason": "Password no longer works.",
    "attachments": [
      "/uploads/complaint/x8Fq2mZk0aLp.webp"
    ],
    "status": "refunded",
    "closedReason": "collab_refund",
    "amountAtStake": "12.5000",
    "alreadyPaidOut": false,
    "collabRespondBy": "2026-02-13T14:20:00.000Z",
    "collabRespondedAt": "2026-02-11T16:00:00.000Z",
    "collabAction": "refund",
    "collabNote": "Sorry — refunded.",
    "buyerConfirmBy": null,
    "buyerConfirmedAt": null,
    "buyerVerdict": null,
    "buyerNote": null,
    "refundAmount": "12.5000",
    "clawbackAmount": "0.0000",
    "resolution": null,
    "resolvedAt": "2026-02-11T16:00:00.000Z",
    "createdAt": "2026-02-11T14:20:00.000Z"
  },
  "message": "The buyer has been refunded"
}
```

---

### Send a fix

```http
POST /api/v1/collab/complaints/:id/fix
```

Replace the disputed account's credentials in place, or re-send a product order's result. The buyer then has a window to confirm.

- **Auth**: `X-Api-Key` — a `collaborator` key (`lk_…`)
- **Ability**: `complaint:respond`
- **Changes state**: yes — this is not a safe request to retry blindly

**Path parameters**

| Field | Type | Constraint | Description |
|---|---|---|---|
| `id` **(required)** | `integer` | digits only | The complaint item id. |

**Request body**

| Field | Type | Constraint | Description |
|---|---|---|---|
| `note` | `string` | max 2000 chars, nullable | What you did. Shown to the buyer. |
| `credentials` | `object` | username required, max 500 chars | Account orders only: { username, password?, extra? } — overwrites the disputed row. |
| `resultNote` | `string` | max 5000 chars | Product orders only: the re-done result. |



The other answer: fix it instead of paying. What "fix" means depends on the
item's `subjectType`:

- `order_item` — send `credentials`. They **overwrite the disputed account
  in place**, encrypted the way the original was; the buyer's order page shows
  the replacement immediately. The old credential is not kept.
- `product_order` — send `resultNote`, the re-done result. The order stays
  `completed`.

Sending the wrong half is a field-level `422`. On success the item becomes
`fixed` and the buyer's clock starts (`buyerConfirmBy`): they either confirm
(the hold is released and your payout resumes on the next run), report it is
still broken (an administrator decides), or say nothing until the clock runs
out — which also releases the hold. There is one round: a buyer who reports
"still broken" goes straight to the administrator.

**Example request**

```bash
curl -X POST 'https://rollroyce.store/api/v1/collab/complaints/301/fix' \
  -H 'X-Api-Key: YOUR_API_KEY' \
  -H 'Origin: https://rollroyce.store' \
  -H 'Content-Type: application/json' \
  -d '{"note":"Replaced with a fresh account.","credentials":"{ \"username\": \"new.user\", \"password\": \"new-pass\" }","resultNote":"Re-delivered, see attached."}'
```

**Response `200`** — The item is now fixed and waiting on the buyer; buyerConfirmBy is their deadline.

```json
{
  "data": {
    "id": 301,
    "complaintCode": "cmp_4c2a9f10-7b3e-4d88-9e61-2f5b8c7d1a90",
    "orderCode": "ord_9f2c1b7e-5a40-4c6d-9b21-8e0a4d3f5c11",
    "subjectType": "order_item",
    "orderItemId": 12044,
    "reason": "Password no longer works.",
    "attachments": [
      "/uploads/complaint/x8Fq2mZk0aLp.webp"
    ],
    "status": "fixed",
    "closedReason": null,
    "amountAtStake": "12.5000",
    "alreadyPaidOut": false,
    "collabRespondBy": "2026-02-13T14:20:00.000Z",
    "collabRespondedAt": "2026-02-11T16:00:00.000Z",
    "collabAction": "fix",
    "collabNote": "Replaced with a fresh account.",
    "buyerConfirmBy": "2026-02-14T16:00:00.000Z",
    "buyerConfirmedAt": null,
    "buyerVerdict": null,
    "buyerNote": null,
    "refundAmount": "0.0000",
    "clawbackAmount": "0.0000",
    "resolution": null,
    "resolvedAt": null,
    "createdAt": "2026-02-11T14:20:00.000Z"
  },
  "message": "Your fix was sent; the buyer has been asked to confirm"
}
```

---

### Upload stock

```http
POST /api/v1/collab/stock
```

File account credentials into a group's pool, in your own name. Up to 5,000 rows per call.

- **Auth**: `X-Api-Key` — a `collaborator` key (`lk_…`)
- **Ability**: `stock:write`
- **Changes state**: yes — this is not a safe request to retry blindly
- **Extra rate limit**: 30 uploads per minute, per account — shared by every key you hold and by the website. The body limit on this route is 8 MB, not the usual 1 MB.

**Request body**

| Field | Type | Constraint | Description |
|---|---|---|---|
| `groupId` **(required)** | `integer` | — | The account group the rows belong to. |
| `filterOption` | `string` | no_filter \| filter_selling \| filter_sold \| filter_all; default no_filter | How far the duplicate guard looks. no_filter = the mandatory floor only (no repeat in the batch, no username already live in this group). |
| `items` **(required)** | `array` | 1–5,000 objects; loginUsername/loginPassword ≤ 255 chars, extra ≤ 5,000 chars, basePrice 0–1,000,000 | The rows. Each has a required loginPassword and optional loginUsername, extra and basePrice. |



File account credentials into a group's pool. Every row is attributed to
**your** profile — there is no owner field to set, and one sent anyway is
dropped before any code reads it. The website's upload uses the same handler
and the same limits.

- `loginPassword` is the one required field per row. `loginUsername` is
  optional (a licence key has no username), but when present it is the
  duplicate guard's key: no two live rows in a group may share it.
- `filterOption` only ever **widens** the guard. `no_filter` is the floor;
  `filter_all` also refuses a username that was ever sold in this group.
- One refused username refuses the whole batch (`E_STOCK_DUPLICATE` names
  them); nothing is written partially.
- Rows land `ready` and start selling at once. If the group is
  `hidden` or `locked` the upload is refused — an administrator took it off
  the market.

Editing or deleting a row you filed is done from the website's
**My stock** page; there is no key-surface route for that.

**Example request**

```bash
curl -X POST 'https://rollroyce.store/api/v1/collab/stock' \
  -H 'X-Api-Key: YOUR_API_KEY' \
  -H 'Origin: https://rollroyce.store' \
  -H 'Content-Type: application/json' \
  -d '{"groupId":12,"filterOption":"no_filter"}'
```

**Response `201`** — Every row written, attributed to your profile. No credential is echoed back.

```json
{
  "data": [
    {
      "id": 9801,
      "groupId": 12,
      "groupName": "Netflix Premium",
      "isAdminStock": false,
      "collaborator": {
        "id": 7,
        "displayName": "Alice",
        "email": "alice@example.com"
      },
      "basePrice": "1.5000",
      "status": "ready",
      "username": "acct-001",
      "buyerId": null,
      "orderItemId": null,
      "priceSell": null,
      "boughtAt": null,
      "createdAt": "2026-02-11T15:02:00.000Z",
      "updatedAt": "2026-02-11T15:02:00.000Z"
    }
  ],
  "message": "Stock added"
}
```

**Errors specific to this endpoint**

| Code | Status | Meaning | What to do |
|---|---|---|---|
| `E_STOCK_DUPLICATE` | 422 | One or more usernames in the batch already exist in this group's pool (or repeat inside the batch). Nothing was written. | The message lists the offenders. Remove them and resend the rest; filterOption widens what counts as a duplicate, never narrows it. |
