API reference
Two API-key surfaces: one for buying from the catalogue, one for fulfilling product orders. Authenticate with an X-Api-Key header and call it from your server.
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:
https://rollroyce.store/api/v1It 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:
{
"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:
{
"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.
GET /api/v1/client/catalog/groups?page=2&perPage=50perPage 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:
GET /api/v1/client/catalog/groups?attributes[region]=EU&attributes[tier]=proArrays 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.
X-Api-Key: ck_NA.eyJzZWNyZXQiOiJleGFtcGxlIn0Two 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.
Origin: https://rollroyce.storeA 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.
Originis 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-Keyheader - 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 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 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
The only way a key is ever created — and the only time its secret is shown.
/api/v1/account/api-keysRequest body
| Field | Type | Constraint | Description |
|---|---|---|---|
| typerequired | 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. |
Example request
curl -X POST 'https://rollroyce.store/api/v1/account/api-keys' \
-H 'X-Api-Key: YOUR_API_KEY' \
-H 'Origin: https://rollroyce.store'{
"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 from 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
Every key on your account. The secret is never among them.
/api/v1/account/api-keysmeta.paginationQuery 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. |
Example request
curl -X GET 'https://rollroyce.store/api/v1/account/api-keys' \
-H 'X-Api-Key: YOUR_API_KEY'{
"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
Kills the key on the next request. Orders it placed are kept.
/api/v1/account/api-keys/:idPath parameters
| Field | Type | Constraint | Description |
|---|---|---|---|
| idrequired | integer | digits only | The key id. |
Example request
curl -X DELETE 'https://rollroyce.store/api/v1/account/api-keys/:id' \
-H 'X-Api-Key: YOUR_API_KEY' \
-H 'Origin: https://rollroyce.store'{
"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 | Granted by default |
|---|---|---|
catalog:read | Browse categories, listing groups and the products inside one.GET /client/catalog/categoriesGET /client/catalog/groupsGET /client/catalog/groups/:idGET /client/catalog/groups/:id/products | yes |
order:quote | Price a basket before committing to it. Creates nothing.POST /client/orders/quote | yes |
order:create | Place an order. Debits the account's wallet.POST /client/orders | yes |
order:read | Read the key owner's own orders — metadata, never credentials.GET /client/ordersGET /client/orders/:code | yes |
credential:read | Decrypt and read the delivered accounts of the owner's own completed `account` orders.GET /client/orders/:code/credentialsGET /client/orders/:code/export | yes |
balance:read | Read the owner's wallet balance and the discount rate currently applied to it.GET /client/me/balance | 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 | Granted by default |
|---|---|---|
product_order:read | The claim queue, the collaborator's own orders, and one order's detail.GET /collab/product-orders/queueGET /collab/product-ordersGET /collab/product-orders/:id | yes |
product_order:claim | Take an unclaimed product order out of the queue.POST /collab/product-orders/:id/claim | yes |
product_order:complete | Mark a held order delivered, which starts its payout clock.POST /collab/product-orders/:id/complete | yes |
product_order:cancel | Cancel an order the collaborator is holding and refund the buyer in full.POST /collab/product-orders/:id/cancel | no — ask for it |
payout:read | Read the collaborator's own commission payout history.GET /collab/payouts | yes |
complaint:read | Read the complaint items filed against the collaborator's own sales.GET /collab/complaintsGET /collab/complaints/:id | 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.POST /collab/complaints/:id/refundPOST /collab/complaints/:id/fix | no — ask for it |
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.POST /collab/stockGET /collab/stockPUT /collab/stock/:idDELETE /collab/stock/:id | yes |
product_order:cancelis 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.
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 |
A lost claim race still spends a claim. The budget is charged before the handler runs, so a
409 E_PRODUCT_ORDER_ALREADY_CLAIMEDcosts 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:
x-ratelimit-limit: 120
x-ratelimit-remaining: 118
x-ratelimit-reset: 37On 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/1.1 429 Too Many Requests
retry-after: 37Back 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
{
"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/orderstimes out, do not blindly resend it — readGET /client/ordersfirst 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.
Customer surface
ck_…Ten endpoints for browsing the catalogue and placing orders as the account that minted the key.
List categories
The public category tree, paginated.
/api/v1/client/catalog/categoriesX-Api-Key, a customer key (ck_…)catalog:readmeta.paginationQuery 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
Try it livecurl -X GET 'https://rollroyce.store/api/v1/client/catalog/categories' \
-H 'X-Api-Key: YOUR_API_KEY'{
"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
Listing groups, filterable and sortable. This is the catalogue.
/api/v1/client/catalog/groupsX-Api-Key, a customer key (ck_…)catalog:readmeta.paginationQuery 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.availableStocksays 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, andsellingPriceon the group itself is0because 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,priceMaxorsort=priceand the listing is restricted totype="account"groups. That is not a bug: a product group'ssellingPriceis0meaning "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
Try it livecurl -X GET 'https://rollroyce.store/api/v1/client/catalog/groups' \
-H 'X-Api-Key: YOUR_API_KEY'{
"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
One listing group, with its live sellable stock count.
/api/v1/client/catalog/groups/:idX-Api-Key, a customer key (ck_…)catalog:readPath parameters
| Field | Type | Constraint | Description |
|---|---|---|---|
| idrequired | 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
Try it livecurl -X GET 'https://rollroyce.store/api/v1/client/catalog/groups/12' \
-H 'X-Api-Key: YOUR_API_KEY'{
"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 from 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
The individual products inside one product-type group.
/api/v1/client/catalog/groups/:id/productsX-Api-Key, a customer key (ck_…)catalog:readmeta.paginationPath parameters
| Field | Type | Constraint | Description |
|---|---|---|---|
| idrequired | 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
Try it livecurl -X GET 'https://rollroyce.store/api/v1/client/catalog/groups/12/products' \
-H 'X-Api-Key: YOUR_API_KEY'{
"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 from 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
Price a basket without creating anything. Nothing is reserved and nothing is charged.
/api/v1/client/orders/quoteX-Api-Key, a customer key (ck_…)order:quoteRequest body
| Field | Type | Constraint | Description |
|---|---|---|---|
| orderTyperequired | string | account | product | Which checkout branch this is. Explicit rather than inferred from the other fields. |
| groupIdrequired | 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
codeis a 422E_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 acodealso spends a separate budget of 10 per minute per account before it is refused, so do not probe.
Example request
Try it livecurl -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}'{
"data": {
"originalAmount": "25.0000",
"discountAmount": "2.5000",
"discountPercent": "10.00",
"paidAmount": "22.5000",
"discountSource": "user_tier"
},
"message": null
}Errors from 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
Creates the order and debits the wallet. This one spends real money.
/api/v1/client/ordersX-Api-Key, a customer key (ck_…)order:createRequest body
| Field | Type | Constraint | Description |
|---|---|---|---|
| orderTyperequired | string | account | product | Same discriminator as the quote. |
| groupIdrequired | 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.
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"— setquantity. Optionallypreorder: trueon a group that allows buying ahead of stock.orderType: "product"— setitems, 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/ordersand check whether the order landed. Resending blindly charges the wallet twice.
Example request
Try it livecurl -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}'{
"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 from 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
Order history for the account the key acts as.
/api/v1/client/ordersX-Api-Key, a customer key (ck_…)order:readmeta.paginationQuery 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
Try it livecurl -X GET 'https://rollroyce.store/api/v1/client/orders' \
-H 'X-Api-Key: YOUR_API_KEY'{
"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
One order by its public code. Metadata only — no credentials.
/api/v1/client/orders/:codeX-Api-Key, a customer key (ck_…)order:readPath parameters
| Field | Type | Constraint | Description |
|---|---|---|---|
| coderequired | 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.
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
Try it livecurl -X GET 'https://rollroyce.store/api/v1/client/orders/ord_9f2c1b7e-5a40-4c6d-9b21-8e0a4d3f5c11' \
-H 'X-Api-Key: YOUR_API_KEY'{
"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 from 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
The decrypted accounts of your own completed account-type order.
/api/v1/client/orders/:code/credentialsX-Api-Key, a customer key (ck_…)credential:readmeta.paginationPath parameters
| Field | Type | Constraint | Description |
|---|---|---|---|
| coderequired | 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
Try it livecurl -X GET 'https://rollroyce.store/api/v1/client/orders/ord_9f2c1b7e-5a40-4c6d-9b21-8e0a4d3f5c11/credentials' \
-H 'X-Api-Key: YOUR_API_KEY'{
"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 from 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
The whole order in one streamed response: a text file, one account per line, or NDJSON.
/api/v1/client/orders/:code/exportX-Api-Key, a customer key (ck_…)credential:readPath parameters
| Field | Type | Constraint | Description |
|---|---|---|---|
| coderequired | 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.
Example request
curl -X GET 'https://rollroyce.store/api/v1/client/orders/ord_9f2c1b7e-5a40-4c6d-9b21-8e0a4d3f5c11/export' \
-H 'X-Api-Key: YOUR_API_KEY'Errors from 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
The wallet balance and the discount rate the account is currently getting.
/api/v1/client/me/balanceX-Api-Key, a customer key (ck_…)balance:readThere 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
Try it livecurl -X GET 'https://rollroyce.store/api/v1/client/me/balance' \
-H 'X-Api-Key: YOUR_API_KEY'{
"data": {
"balance": "184.2500",
"discountPercent": "10.00",
"discountSource": "user_tier"
},
"message": null
}Collaborator surface
lk_…Seven endpoints for taking product orders off the queue, fulfilling them, and reading what they paid.
The claim queue
Unclaimed product orders waiting for a collaborator.
/api/v1/collab/product-orders/queueX-Api-Key, a collaborator key (lk_…)product_order:readmeta.paginationQuery 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
Try it livecurl -X GET 'https://rollroyce.store/api/v1/collab/product-orders/queue' \
-H 'X-Api-Key: YOUR_API_KEY'{
"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 from 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
Orders this collaborator has claimed, in any state.
/api/v1/collab/product-ordersX-Api-Key, a collaborator key (lk_…)product_order:readmeta.paginationQuery 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.
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
Try it livecurl -X GET 'https://rollroyce.store/api/v1/collab/product-orders' \
-H 'X-Api-Key: YOUR_API_KEY'{
"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 from 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
One order in full, including the buyer's answers to the group's questions.
/api/v1/collab/product-orders/:idX-Api-Key, a collaborator key (lk_…)product_order:readPath parameters
| Field | Type | Constraint | Description |
|---|---|---|---|
| idrequired | 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
Try it livecurl -X GET 'https://rollroyce.store/api/v1/collab/product-orders/5512' \
-H 'X-Api-Key: YOUR_API_KEY'{
"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 from 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
Take an order out of the queue. Once claimed it is yours to finish.
/api/v1/collab/product-orders/:id/claimX-Api-Key, a collaborator key (lk_…)product_order:claimPath parameters
| Field | Type | Constraint | Description |
|---|---|---|---|
| idrequired | 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 and 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
Try it livecurl -X POST 'https://rollroyce.store/api/v1/collab/product-orders/5512/claim' \
-H 'X-Api-Key: YOUR_API_KEY' \
-H 'Origin: https://rollroyce.store'{
"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 from 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
Report the work done. Starts the payout clock.
/api/v1/collab/product-orders/:id/completeX-Api-Key, a collaborator key (lk_…)product_order:completePath parameters
| Field | Type | Constraint | Description |
|---|---|---|---|
| idrequired | 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
Try it livecurl -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."}'{
"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 from 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
Give up an order you are holding. The buyer is refunded in full.
/api/v1/collab/product-orders/:id/cancelX-Api-Key, a collaborator key (lk_…)product_order:cancelPath parameters
| Field | Type | Constraint | Description |
|---|---|---|---|
| idrequired | 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, 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:cancelis 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
Try it livecurl -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."}'{
"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 from 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
Payouts already released to you. Money still in escrow is on your product orders, not here.
/api/v1/collab/payoutsX-Api-Key, a collaborator key (lk_…)payout:readmeta.paginationQuery 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, where:
payoutReleaseAtin the future,payoutPaidAtnull — done and still in escrow.payoutReleaseAtin the past,payoutPaidAtnull — 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
Try it livecurl -X GET 'https://rollroyce.store/api/v1/collab/payouts' \
-H 'X-Api-Key: YOUR_API_KEY'{
"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 from 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
Complaint items filed against your sales — the ones waiting on you and the ones already answered.
/api/v1/collab/complaintsX-Api-Key, a collaborator key (lk_…)complaint:readmeta.paginationQuery 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 beforecollabRespondBy, or the buyer is refunded automatically and, if the money had reached you, clawed back.fixed— you sent a fix and the buyer has untilbuyerConfirmByto 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
Try it livecurl -X GET 'https://rollroyce.store/api/v1/collab/complaints' \
-H 'X-Api-Key: YOUR_API_KEY'{
"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
One item of yours. Somebody else's item is a 404, never a 403.
/api/v1/collab/complaints/:idX-Api-Key, a collaborator key (lk_…)complaint:readPath parameters
| Field | Type | Constraint | Description |
|---|---|---|---|
| idrequired | 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
Try it livecurl -X GET 'https://rollroyce.store/api/v1/collab/complaints/301' \
-H 'X-Api-Key: YOUR_API_KEY'{
"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
Close the item in the buyer's favour. If the money had already been paid out to you, it is clawed back.
/api/v1/collab/complaints/:id/refundX-Api-Key, a collaborator key (lk_…)complaint:respondPath parameters
| Field | Type | Constraint | Description |
|---|---|---|---|
| idrequired | 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
Try it livecurl -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."}'{
"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
Replace the disputed account's credentials in place, or re-send a product order's result. The buyer then has a window to confirm.
/api/v1/collab/complaints/:id/fixX-Api-Key, a collaborator key (lk_…)complaint:respondPath parameters
| Field | Type | Constraint | Description |
|---|---|---|---|
| idrequired | 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— sendcredentials. 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— sendresultNote, the re-done result. The order stayscompleted.
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
Try it livecurl -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."}'{
"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"
}