Nexus Shop API
This document describes how your shop communicates securely with the Nexus platform.
Base URL
Every path in this document is relative to the base URL of the Nexus environment your shop is connected to. For production that is:
https://nexus.amadeustech.dev
So GET /api/v3/catalog means GET https://nexus.amadeustech.dev/api/v3/catalog.
The Control Panel where you obtain your Shop token lives at the same address.
API v3
/api/v3 is the current version of the Shop API, and this document describes
it. /api/v1 is deprecated: what differs and how to move is in
Deprecated: API v1, at the end of this document.
What v3 gives you:
- A product looks the same every day. Its
name,icon,typeanddeliveryDataform belong to the product and change only when the product itself does. On v1 they could change from one request to the next. - Consistent field ids. Within a game the same field always carries the same
id, named after what it means (
player_id,server,email, …). - No duplicate products. The same product is listed once.
- Fewer failed orders. An order is validated against the form you were shown, and only against it. When the cheapest source cannot serve the server or the quantity the buyer chose, the order goes to the next source instead of failing.
- Consistent reads.
GET /api/v3/products/<id>,/games/<id>and/categories/<id>answer from the same listing asGET /api/v3/catalog, so a product you see in one is the product you get in the other.
Not available on v3 yet
- Roblox SuperPass.
roblox:passes:<id>products are not on v3: read and order them through/api/v1(see Get games which support SuperPasses purchase). Other Roblox products, including Robux via pass, are on v3. - Bundle products (see Bundles & partial fulfillment) are not listed.
What's new
Changes to the Shop API, newest version first. Each entry says what changed and whether you need to do anything.
What's new in 1.10.3
1.10.3 is 1.10.2 together with the speed-up released to production as
1.9.3: the v1 catalog listing with include=metadata is cached like the
plain one, and the first /api/v3 request after a catalog change is faster
(see What's new in 1.9.3 below, with response times measured on production).
The contract did not change since 1.10.2. Live in production since
2026-11-07.
What's new in 1.10.2
1.10.2 is 1.10.1 together with the speed-up released to production as 1.9.2: catalog listings are assembled from cached layers and answer faster (see What's new in 1.9.2 below). The contract did not change since 1.10.1.
What's new in 1.10.1
1.10.1 is 1.10.0 together with the fix released to production as 1.9.1: a
product or game without an icon has icon: null (see What's new in 1.9.1
below). Nothing else changed since 1.10.0.
What's new in 1.10.0
New product type account: a ready-made account arrives as named fields.
Some products are a ready-made account of a service (an AI tool, a VPN, a video
editor): the buyer gets a login and a password to sign in with, not a code to
redeem. Until now these products were listed as cdkey, and the login and
password came joined into one string in cdKeys[].code, for example
[email protected]|Abc123!, which buyers took for an activation code. Now:
- such a product has
type: "account"; - its order carries the new
accountsarray: one object per delivered account, with each piece of data under its own key (login,password, and when the supplier gives themrecoveryEmail,note,expiry). See Accounts; accountsisnullfor every other product type.cdKeysworks as before forcdkeyandgift_card.
What to do. Accept account as a product type. For an account order,
show the buyer every key/value of each accounts record, with the key as the
label, instead of a code. More keys can appear later, so do not drop the ones
you do not know.
Transition. Until the next version an account order also carries the
same data joined into cdKeys[].code, exactly as today, so a shop that has not
updated still shows something. Do not build on that copy: it will be removed,
and cdKeys of an account order will then be null.
The change applies to /api/v3 and /api/v1 alike: listings, order responses
and webhooks. It went live in production on 2026-11-07, as part of 1.10.3.
The sandbox has test products in test-1:det-accounts and
test-2:rnd-accounts.
What's new in 1.9.3
The v1 catalog listing with include=metadata is cached like the plain
listing, and the first /api/v3 request after a catalog change no longer
pays for a whole reload at once. Responses are byte-for-byte the same as in 1.9.2 — nothing in
the contract changed.
Measured on production, before 1.9.2 (3–5 November) and after 1.9.3 (6–7 November, 44 hours). The median is a typical request, p95 the slowest one in twenty:
| Request | Before | After |
|---|---|---|
GET /api/v3/catalog, median | 1.2 s | 0.66 s |
GET /api/v3/catalog, p95 | 2.4 s | 1.9 s |
GET /api/v3/products, p95 | 1.1 s | 14 ms |
| v1 games listing, median | 776 ms | 2 ms |
| v1 catalog listing, p95 | 1.3 s | 0.9 s |
POST /api/v3/orders, p95 | 2.9 s | 2.7 s |
Before, the server paused for a second or more in 16% of five-second intervals, and any request arriving then, orders included, waited. Now that happens in 0.1% of them.
What's new in 1.9.2
Faster listings, same bytes. The catalog listings (GET /api/v3/catalog,
/products, /categories, /games, and the v1 /catalog and /games) are
assembled from cached layers and answer in milliseconds once your shop has
requested them after a catalog refresh (every 15 minutes); the first request
after a refresh is also faster than before. Responses are byte-for-byte the
same as in 1.9.1 — nothing in the contract changed. A change to your shop's
filter rules or fee is visible on the next request. Response times measured on
production for 1.9.2 and 1.9.3 together are under What's new in 1.9.3.
What's new in 1.9.1
A product or game without an icon now has icon: null.
About 250 products (mostly Steam keys) have no icon. Their icon used to be
the URL of the icons folder itself (…/static/icons/), which answers 404.
It is now null, on /api/v3, on /api/v1 and in gameIcon of
GET /api/v3/hot-products; it is never "". If you read icon or
gameIcon as a string, handle null: show your own placeholder and do not
request an image. Products and games that have an icon are unchanged.
Nothing else in the contract changed.
What's new in 1.9.0
/api/v1 is deprecated, and /api/v3 is now the main version of the API.
This document now describes v3. /api/v1 keeps working; what differs and how to
move is in Deprecated: API v1. The document that used to
live at /docs/api/v3 is now this one, at both addresses.
What's new in 1.8.1
The PUBG player check works again, and says when it can't answer.
GET /api/v3/utils/pubg/player/<id> answers with the nickname again. When the
check cannot be done right now it responds 503 with
{ "error": "Service temporarily unavailable" } instead of 502, so a buyer is
not told their id is wrong: retry later (see PUBG). The sandbox answers 503
for the id 2.
The documentation also showed the wrong body for an unknown player. It is
{ "username": null }, as the API has always answered, not { "playerName": null }.
Nothing else in the contract changed.
What's new in 1.8.0
Steam logins can be checked before the buyer pays.
GET /api/v3/utils/steam/account/:login tells whether a Steam account with
this login exists: 200 {"exists": true} or 404 with "code": "not_found".
A wrong login is the most common reason a Steam balance top-up fails with
INVALID_CREDENTIALS. Check the login at checkout and ask the buyer to fix it
there (see Steam). The check confirms the login only: no nickname, nothing
about region or restrictions. The sandbox answers 404 for the login
nosuchlogin and 502 for lookupfailed.
Nothing else in the contract changed.
What's new in 1.7.2
Every activation-code product now carries an instruction. A cdkey or
gift_card product with no instruction of its own now falls back to a default
one (a continuous screen recording of the purchase and activation is
required), so haveInstruction is true for all of them. Nothing in the
response shape changes; if you show the instruction whenever
haveInstruction is true, you need to do nothing (see Form instructions).
What's new in 1.7.1
The Free Fire lookup's region can now be null.
GET /api/v3/utils/fireloot/player/:uid used to report every account outside
the CIS market as BANGLADESH, including Brazilian and Middle East players.
Only the CIS market can actually be determined, so for any other account the
lookup still confirms the id and returns the nickname, but region is null
("market unknown"). If you read region as a string, handle null: do not
block the buyer and do not pick a product line for them from it; let them
choose their market (see Free Fire). The sandbox answers region: null for
player id 3.
Also in this release:
- an order the supplier could not complete now fails as
PROVIDER_ERRORinstead ofUNKNOWN_ERROR; - the Roblox pass lookups answer
503instead of a bare500while the supplier behind them is down.
What's new in 1.7.0
Errors now say what to fix. Successful responses are unchanged.
- Authentication failures are
401with a reason. A missing, malformed or non-matching shop token used to get a bare403 Forbiddentext. It now gets401, aWWW-Authenticate: Shopheader and a JSON body{"errorCode": "UNAUTHORIZED", "errorMessage": "..."}that names the problem: no header, wrong scheme, token not shaped<shopId>:<token>, or a token that does not match, for example one regenerated in the Control Panel (see Authentication). If you detect a bad token by status403, also accept401. - A disabled shop still gets
403, now as JSON with"errorCode": "SHOP_DISABLED". - A body that is not valid JSON is a
400, not a500. It comes back as{"errorCode": "BAD_REQUEST", "errorMessage": "Malformed JSON in request body"}. Do not retry it: fix the request.
What's new in 1.6.0
Large orders stop failing for lack of supply — and you can see it coming. Everything here is additive; a client that changes nothing keeps working.
- How much can be bought right now.
GET /api/v3/products/<id>carriesavailableAmountwhen the purchasable amount of a quantity product is currently limited (see Product.availableAmount). When it is not limited the field is absent and the response is unchanged. - Quotes.
GET /api/v3/products/<id>?amount=90adds aquote: what that amount costs you, how much of it can be sourced, and in which parts (see Quotes). NOT_ENOUGH_STOCKinstead of "temporarily unavailable". An order for more than can be sourced used to answerPROVIDER_ERROR(503), which says "retry later" about a product you could have sold a smaller amount of that minute. It now answersNOT_ENOUGH_STOCK(400) withavailableAmount— the amount that would go through. NewerrorCodevalue: accept it before relying on strict enum validation.- Split orders (opt-in). With
"allowSplit": truea request that one source cannot fill is placed as two ordinary orders with the samedeliveryData, and you get both back (see Split orders). Without the flag nothing changes. maxTotalPrice. Set it on an order and a live price above it is rejected withPRICE_CHANGED(409) instead of being charged.- A quieter fix. An order is no longer validated against a form you were
never shown. When the supplier you were quoted could not take the order and
another one had different form fields, correct customer data came back as
INVALID_CREDENTIALS; such a supplier is now passed over.
What's new in 1.5.1
A sold-out product no longer answers INVALID_AMOUNT. For products whose
amountType.max tracks the supplier's live stock, an exhausted batch used to
reject an ordinary "amount": 1 with INVALID_AMOUNT / "Amount must be >= 1
<= 0 with step 1" — an amount rule no value could satisfy. Such an order now
goes to another supplier of the same product automatically; if there is none,
it comes back as PRODUCT_NOT_FOUND (refresh the catalog) or PROVIDER_ERROR
(retry later), never as a problem with your payload. Nothing to change on your
side beyond not treating stock-outs as an amount error.
What's new in 1.5.0
Free Fire orders can be checked before the buyer pays.
GET /api/v3/utils/fireloot/player/:uid resolves a Free Fire player id to a
nickname and — more importantly — to the market the account belongs to:
- Free Fire is sold as a separate product line per market
(
free-fire:diamonds-cis,free-fire:pass-cis, …), and an order whose product belongs to a different market than the account is rejected on delivery. Useregionto offer the right nominals up front instead of letting the buyer discover the mismatch at checkout; - a valid id can still come back without a nickname — check
nameCheckedbefore showing one (see Free Fire).
The endpoint itself has been live since the 1.3.0 line; this release documents it. Nothing else in the contract changed.
What's new in 1.4.0
A new product line needs a change on your side. Products under
roblox:robux-login (Robux credited by signing in to the buyer's own Roblox
account) cannot be delivered unless you show the buyer a verification link:
- handle the new
robloxLogpassVerificationwebhook (see Webhooks), or read the link back fromGET /api/v3/utils/roblox/logpass-order/:orderId/verification; - these orders stay
pendinguntil the buyer signs in, and are never cancelled by timeout — an order whose link the buyer never saw waits forever and freezes the money; - the delivery form gained a
secretfield type for the optional password and recovery code (see Secret type). Collecting them is optional but shortens the buyer's journey; render them masked and never store them.
Also in this release: every product carries an instruction for this line — check
haveInstruction and show it at checkout (see Form instructions).
Authentication
All requests to the Nexus Shop API must be authenticated.
Set the Authorization header using the following format:
Authorization: Shop <token>
Example:
Authorization: Shop 111:aaabbbccc
You can obtain (or rotate) your Shop token in the Control Panel. Treat the token as a secret and never expose it in client-side code or logs.
If the header is missing or invalid the API responds with 401 and a
WWW-Authenticate: Shop header. The body says what to fix:
{
"errorCode": "UNAUTHORIZED",
"errorMessage": "Shop token is not valid. If it was regenerated, copy the current token from My shops in the Control Panel."
}
The message distinguishes a missing header, a scheme other than Shop, a
token not shaped <shopId>:<token>, a user session sent instead of a shop
token, and a token that does not match (for example, one that was regenerated
in the Control Panel). Regenerating a token invalidates the old one at once.
A valid token of a disabled shop gets 403:
{ "errorCode": "SHOP_DISABLED", "errorMessage": "Your shop is disabled" }
A request body that is not valid JSON gets 400 with
"errorCode": "BAD_REQUEST" and the parser's message, e.g.
"Malformed JSON in request body". On Windows, prefer sending the body from a
file (curl.exe ... --data "@order.json"): cmd and PowerShell do not treat
single quotes like a Unix shell and break inline JSON.
API Endpoints
Data format
type Game = {
id: string;
name: string;
icon: string | null; // URL of the icon; null when the game has no icon
tags: string[]; // tag keys; see GET /api/v3/tags for localized labels
};
type Category = {
id: string;
name: string;
};
type Product = {
id: string;
name: string;
icon: string | null; // URL of the icon; null when the product has no icon
price: number;
deliveryData: FormField[];
type: "topup" | "cdkey" | "gift_card" | "subscription" | "account";
// Activation platform of the code / gift card; null when not applicable.
platform: Platform | null;
// Whether GET /api/v3/instructions/<id> has an instruction for this
// product. Only call that endpoint when true — probing blindly is wasted
// traffic (the endpoint answers 404 for most products). A missing key
// means the platform predates this field; treat it as "unknown, may probe".
haveInstruction: boolean;
amountType: ProductAmountType;
description?: string;
// Descriptive metadata about the product. Present ONLY when the request asks
// for it with `?include=metadata`; `null` means we hold nothing for this
// product. See "Product metadata".
metadata?: ProductMetadata | null;
};
type ProductMetadata = {
// --- light half: returned by ?include=metadata on list endpoints ---
releaseDate?: string; // ISO-8601 date
isReleased?: boolean; // false = pre-order, always with a future releaseDate
developer?: string;
publisher?: string;
genres?: string[];
languages?: string[]; // ISO-639-1 codes supported by the product itself
ageRating?: { system: "pegi"; age?: number; descriptors: string[] };
region?: {
regions: string[]; // macro-regions, e.g. ["EU"]
countries: string[]; // ISO-3166-1 alpha-2
excludedCountries: string[]; // carve-outs, e.g. ["BY", "RU"]
global: boolean;
};
countryPolicyKind?: "worldwide" | "allow" | "deny";
// --- rich half: only on /products/:id and /catalog/metadata ---
about?: ContentBlock[];
requirements?: {
minimal?: RequirementsSpec;
recommended?: RequirementsSpec;
};
countryPolicy?:
| { kind: "worldwide" }
| { kind: "allow"; countries: string[] }
| { kind: "deny"; countries: string[] };
activationNotice?: string;
media?: { images?: string[]; video?: { provider: "youtube"; id: string } };
redeemUrl?: string;
notes?: string[];
contents?: { name: string; quantity?: number; rarity?: string }[];
tags?: string[];
};
// Product copy, pre-parsed into blocks. Plain text only — never HTML — so it can
// be rendered without `dangerouslySetInnerHTML`.
type ContentBlock = {
type: "p" | "h" | "li" | "quote";
text: string;
};
type RequirementsSpec = {
os?: string;
processor?: string;
memory?: string;
graphics?: string;
storage?: string;
other?: string;
};
type Platform =
| "steam"
| "xbox"
| "psn"
| "nintendo"
| "gog"
| "epic"
| "ea"
| "ubisoft"
| "google_play"
| "apple"
| "other";
type FormField =
| {
type: "text";
id: string;
label: string;
required: boolean;
regex?: string;
}
| {
type: "number";
id: string;
label: string;
required: boolean;
step?: number;
min?: number;
max?: number;
}
| {
type: "select";
id: string;
label: string;
required: boolean;
options: {
id: string;
label: string;
}[];
}
| {
type: "secret";
id: string;
label: string;
required: boolean;
regex?: string;
};
type ProductAmountType =
| {
type: "one";
}
| {
type: "multiple";
min?: number | undefined;
max?: number | undefined;
step?: number | undefined;
};
Full Catalog
GET /api/v3/catalog retrieve all games, categories and products in a single request.
GET /api/v3/catalog
Authorization: Shop 111:aaabbbccc
{
"games": [
{
"id": "highrise",
"name": "Highrise",
"icon": "https://cdn.example.com/icons/highrise.png"
}
],
"categories": [
{ "id": "highrise:gold_pack", "name": "Gold Packs" },
{ "id": "highrise:season_pass", "name": "Season Pass" }
],
"products": [
{
"id": "highrise:gold_pack:999_v2",
"name": "Gold Pack — 999",
"icon": "https://cdn.example.com/products/gold_999.png",
"price": 9.99,
"deliveryData": [
{
"type": "text",
"id": "username",
"label": "Username",
"required": true,
"regex": "\\S+"
}
],
"type": "topup",
"platform": null,
"haveInstruction": true,
"amountType": { "type": "one" }
}
]
}
The response contains three flat arrays. Relationships are encoded in IDs:
category.idstarts with the parentgame.id(e.g.highrise:gold_packbelongs to gamehighrise)product.idstarts with the parentcategory.id(e.g.highrise:gold_pack:999_v2belongs to categoryhighrise:gold_pack)
Products include the same fee and delivery data transformations as GET /api/v3/products. The response is cached server-side for up to 5 minutes.
Games
GET /api/v3/games/ retrieve a list of all available games in Game[] format.
GET /api/v3/games/
Authorization: Shop 111:aaabbbccc
[
{
"id": "highrise",
"name": "Highrise",
"icon": "https://cdn.example.com/icons/highrise.png"
},
{
"id": "sandbox",
"name": "Sandbox",
"icon": "https://cdn.example.com/icons/sandbox.png"
}
]
GET /api/v3/games/<id> retrieve a specific game in Game format.
GET /api/v3/games/highrise
Authorization: Shop 111:aaabbbccc
{
"id": "highrise",
"name": "Highrise",
"icon": "https://cdn.example.com/icons/highrise.png"
}
Explanation for type Game
| Field | Description |
|---|---|
icon | A URL for icon file, or null when the game has no icon. Never "" |
Tags
GET /api/v3/tags returns the platform's service tags. Tag name is localized
via Accept-Language (see Localization); key is a stable identifier used for
filtering and in each game's tags array.
GET /api/v3/tags
Authorization: Shop 111:aaabbbccc
Accept-Language: ru
[
{ "key": "games", "name": "Игры" },
{ "key": "gift-cards", "name": "Подарочные карты" }
]
GET /api/v3/games?tag=<key> returns only the games attached to that tag (still
subject to the shop's catalog visibility). Each Game carries its own tags
array of tag keys.
GET /api/v3/games?tag=games
Authorization: Shop 111:aaabbbccc
[
{ "id": "highrise", "name": "Highrise", "icon": "https://cdn.example.com/icons/highrise.png", "tags": ["games"] }
]
Categories
GET /api/v3/categories?gameId=<id> retrieve a list of all available categories
in Category[] format for a specific game. gameId search param is
required.
GET /api/v3/categories?gameId=highrise
Authorization: Shop 111:aaabbbccc
[
{ "id": "gold_pack", "name": "Gold Packs" },
{ "id": "season_pass", "name": "Season Pass" }
]
GET /api/v3/categories/<id> retrieve a specific category in Category format.
GET /api/v3/categories/gold_pack
Authorization: Shop 111:aaabbbccc
{
"id": "gold_pack",
"name": "Gold Packs"
}
Products
GET /api/v3/products?categoryId=<id> retrieve a list of all available products
in Product[] format for a specific category. categoryId search param is
required.
GET /api/v3/products?categoryId=gold_pack
Authorization: Shop 111:aaabbbccc
[
{
"id": "highrise:gold_pack:999_v2",
"name": "Gold Pack — 999",
"icon": "https://cdn.example.com/products/gold_999.png",
"price": 9.99,
"deliveryData": [
{
"type": "text",
"id": "username",
"label": "Username",
"required": true,
"regex": "\S+"
},
{
"type": "select",
"id": "serverId",
"label": "Server",
"required": true,
"options": [
{ "id": "eu", "label": "Europe" },
{ "id": "us", "label": "USA" },
{ "id": "ru", "label": "Russia" }
]
}
],
"type": "topup",
"platform": null,
"amountType": { "type": "one" }
},
{
"id": "highrise:gold_pack:bulk_100",
"name": "Gold Pack — Bulk",
"icon": "https://cdn.example.com/products/gold_bulk.png",
"price": 0.19,
"deliveryData": [
{
"type": "text",
"id": "username",
"label": "Username",
"required": true,
"regex": "\S+"
}
],
"type": "cdkey",
"platform": "steam",
"amountType": { "type": "multiple", "min": 50, "step": 1 }
}
]
GET /api/v3/products/<id> retrieve a specific product in Product format.
GET /api/v3/products/highrise:gold_pack:999_v2
Authorization: Shop 111:aaabbbccc
{
"id": "highrise:gold_pack:999_v2",
"name": "Gold Pack — 999",
"icon": "https://cdn.example.com/products/gold_999.png",
"price": 9.99,
"deliveryData": [
{
"type": "text",
"id": "username",
"label": "Username",
"required": true,
"regex": "\\S+"
},
{
"type": "select",
"id": "serverId",
"label": "Server",
"required": true,
"options": [
{ "id": "eu", "label": "Europe" },
{ "id": "us", "label": "USA" },
{ "id": "ru", "label": "Russia" }
]
}
],
"type": "topup",
"platform": null,
"amountType": { "type": "one" }
}
Product.availableAmount
GET /api/v3/products/steam:balance:usd
Authorization: Shop 111:aaabbbccc
{
"id": "steam:balance:usd",
"price": 0.9999,
"amountType": { "type": "multiple", "min": 0.01, "step": 0.01 },
"availableAmount": 83.81
}
availableAmount is the largest amount one order can buy right now. It is
present only on GET /api/v3/products/<id> (never in the list or the full
catalog, which you are expected to cache), only for products whose amountType
is multiple, and only while supply is actually tight — once roughly $150 worth
or more can be bought the field is omitted, and an omitted field means "no limit
worth enforcing". Use it to cap a quantity input; do not treat it as a
reservation — it moves with every order, yours and everyone else's. The
authoritative answer is always the order itself (NOT_ENOUGH_STOCK).
amountType.max is a different thing: a fixed rule of the product, violated
with INVALID_AMOUNT. availableAmount is never above it.
Add ?allowSplit=true to get the figure for an order placed with
"allowSplit": true — what two orders could buy together.
Quotes
GET /api/v3/products/steam:balance:usd?amount=90&allowSplit=true
Authorization: Shop 111:aaabbbccc
{
"id": "steam:balance:usd",
"price": 0.9999,
"quote": {
"amount": 90,
"fillableAmount": 90,
"totalPrice": 90.0536,
"parts": [
{ "amount": 83.81, "unitPrice": 0.9999, "totalPrice": 83.8017 },
{ "amount": 6.19, "unitPrice": 1.01, "totalPrice": 6.2519 }
]
}
}
| Field | Description |
|---|---|
amount | The amount you asked about |
fillableAmount | How much of it can be sourced right now. Equal to amount when all of it can; 0 when none |
totalPrice | What fillableAmount costs you, your fee included |
parts | How fillableAmount would be sourced. One entry without allowSplit; up to two with it. unitPrice and totalPrice include your fee |
A quote is for the same kind of order you would place: pass allowSplit=true
if the order will carry "allowSplit": true, omit it otherwise. price on the
product stays the unit price of the cheapest source; with a split the blended
price is quote.totalPrice / quote.fillableAmount.
A quote is not a reservation and its prices are the catalog's, while an
order is always re-priced live. To be certain you are not charged more than you
showed your customer, send maxTotalPrice with the order. An amount the
product's amountType rejects answers 400 INVALID_AMOUNT, as an order would.
Product type
| Field | Description |
|---|---|
icon | A URL for icon file, or null when the product has no icon. Never "" |
price | Price in USD for 1 unit of product |
type | Type of product |
platform | Activation platform of the code / gift card, or null — see below |
deliveryData | Specifies which data is required from the customer to complete the order |
amountType | Specifies the rules for the amount field that apply to this product |
metadata | Descriptive product metadata; opt-in via ?include=metadata — see below |
Product.platform
The platform the customer redeems the product on. Intended for storefront
platform filters and Steam/Xbox/PSN badges, and therefore most relevant for
type: "cdkey" and type: "gift_card".
The value is one of a fixed set of machine slugs:
| Field value | Description |
|---|---|
steam | Steam (Valve) |
xbox | Xbox / Microsoft Store / Game Pass |
psn | PlayStation Network |
nintendo | Nintendo eShop / Switch Online |
gog | GOG.com |
epic | Epic Games Store |
ea | EA app / EA Play / Origin |
ubisoft | Ubisoft Connect |
google_play | Google Play |
apple | App Store / iTunes |
other | Redeemed somewhere else (e.g. a streaming-service gift card) |
platform is null when no activation platform applies:
- always for
type: "topup"andtype: "subscription"— these are activated by us directly on the buyer's account, so there is no store to send anyone to; - always for
type: "account"— the buyer signs in to the service with the delivered account, there is nothing to redeem; - for a
cdkey/gift_cardproduct not yet classified on our side.
The key is always present in the product object, so an explicit null means
"not applicable / unknown", while a missing key means the Nexus instance predates
this field.
The value is resolved per product with the most specific classification winning: product → its category → its game. A product's platform therefore does not depend on which provider currently serves it, and a broad classification never leaks onto a top-up that happens to sit in the same category as a key.
Product.type
| Field value | Description |
|---|---|
topup | Direct delivery to account |
cdkey | You will receive a CD key for product in webhook |
gift_card | You will receive a CD key for product in webhook (semantically a gift card; same delivery channel as cdkey) |
subscription | Direct delivery to account (semantically a period subscription; same delivery channel as topup) |
account | You will receive a ready-made account (login, password and more) in the order's accounts — see Accounts |
Product.deliveryData
deliveryData field in the Product type is an array of fields that must be
filled in by the customer to purchase this product.
The following properties are common for all field types
| Prop | Description |
|---|---|
| id | Unique ID of field. Can be used as name in html form |
| label | Label of field for customer in english |
| required | Indicates whether this field is mandatory or not |
| type | Indicates the field type |
Text type
A simple text field. Can have a regex field that applies rules to the input.
Number type
Number field. Can have min, max and step configuration identical to HTML
attributes for an input of type number (<input type="number"/>).
Select type
A select field that provides a list of options from which the user must choose
one. Options are an array of objects with id and label. The label is the
English name of the option displayed to the customer. The id is the actual
value that must be sent during order creation.
Secret type
A credential belonging to the customer — today a Roblox account password or a
Microsoft recovery code. On the wire it behaves like a text field and can have
a regex, but it carries obligations a text field does not:
- Render it masked (
<input type="password"/>) and never pre-fill it. - Do not store it. It exists only to be forwarded to the supplier for this
one order. We do not keep it either: it is erased from our order record as
soon as the supplier accepts the order, and every response and webhook that
echoes
deliveryDataback to you shows it as"[hidden]". - A secret field is never
requiredon a product where the customer has another way through — for logpass they can type the credential on the supplier's verification page instead. Leaving it out is always a valid order.
Never send an empty string for an optional secret field; omit the key instead.
Usage on order creation
When creating an order, you must collect the required data from the customer and
send it in the deliveryData field as a dictionary. For example, for a product
with the following delivery data
{
...
"deliveryData": [
{
"type": "text",
"id": "username",
"label": "Username",
"required": true,
"regex": "\S+"
},
{
"type": "select",
"id": "serverId",
"label": "Server",
"required": true,
"options": [
{
"id": "eu",
"label": "Europe",
},
{
"id": "us",
"label": "USA",
},
{
"id": "ru",
"label": "Russia",
}
]
},
],
...
}
In a new order request, you must send something like this
{
...
"deliveryData": {
"username": "foo",
"serverId": "eu"
},
...
}
Product.amountType
If field has type one this means the product can only be purchased once per
order. And amount in order request must be equal 1. For example
{
"type": "one"
}
If field has type multiple this means the product can have multiple quantities
in each order. This type can also declare minimal and maximal value for
amount. If not declared, there are no limits. Also it can have
step field which indicates the minimum step between values.
For example "step": 1 means that amount must be multiple of 1 and must
be an integer value like 1, 2, 3 and can't be 1.5. For step equal 0.01
amount can be 1.99, 2.5 but not a 1.001.
In other words, for a valid amount, the following equation must be satisfied
amount % step = 0, where % is remainder operator.
Example configuration
{
"type": "multiple",
"min": 50,
"step": 1
}
For some products max mirrors the supplier's live stock, so it moves between
catalog reads. Two consequences: ordering more units than the current max
fails with INVALID_AMOUNT even though the catalog allowed it moments ago (re-read
the product and order the smaller quantity), and a product whose stock ran out
drops out of the catalog entirely — an order placed against it in that window
answers PRODUCT_NOT_FOUND or PROVIDER_ERROR, never INVALID_AMOUNT. A
sold-out product is dropped from the catalog rather than published with
max: 0.
Product descriptions
Products may include an optional description field with a human-readable
description of the product. Products that have no description configured
omit the field from the response — there is no placeholder.
When a request includes Accept-Language and resolves to an enabled
language, the description follows the same fallback chain as name:
- If a translation exists for the resolved language and product slug, the translated description is returned.
- Otherwise, if a default description is configured for the product slug, the default description is returned.
- Otherwise,
descriptionis omitted from the response.
The fallback applies independently per product — a product without a translation but with a default description still returns the default text; a product without either remains untranslated and undescribed.
description is curated by us and is independent of metadata.about, which is
harvested from upstream sources. A product can have either, both, or neither.
Product metadata
Descriptive facts about the product itself — copy, screenshots, trailer, genres, system requirements, age rating, activation restrictions. Everything here is a property of the product, not of any particular supply route, and it does not change when the product's price or availability does.
Metadata is opt-in: without ?include= every response is byte-identical to
what it was before this feature existed.
| Request | Returns |
|---|---|
GET /api/v3/products?categoryId=…&include=metadata | the light half on every product |
GET /api/v3/catalog?include=metadata | the light half on every product |
GET /api/v3/products/<id>?include=metadata | the full object |
GET /api/v3/catalog/metadata | the full object for many products — the recommended integration |
The split exists for size. The light half is ~150–250 bytes per product; the
full object can carry ~3 KB, and the whole catalog is tens of thousands of
products, so full metadata is never inlined into a listing. List endpoints
return the light half regardless of what include asks for — use
/products/<id> or /catalog/metadata to get the rest.
metadata is null when we hold nothing for that product. An absent key
means metadata was not requested (or the deployment predates the feature) — the
same convention as platform.
Bulk sync
GET /api/v3/catalog/metadata walks the whole metadata set, newest changes
last, and is designed to be mirrored into your own storage.
GET /api/v3/catalog/metadata?updatedSince=2026-07-01T00:00:00Z&limit=200
Authorization: Shop 111:aaabbbccc
{
"items": [
{
"productId": "gta-v:keys:standard",
"version": "9f2c…",
"updatedAt": "2026-07-26T19:41:03.117Z",
"metadata": { "developer": "Rockstar North", "…": "…" }
}
],
"nextCursor": "MTc4NTEwMjkxMzA4NXxndGEtdjprZXlzOnN0YW5kYXJk",
"syncedAt": "2026-07-27T08:12:44.001Z"
}
| Param | Meaning |
|---|---|
updatedSince | ISO-8601 timestamp or epoch milliseconds. Omit for a full walk |
cursor | Opaque; pass back nextCursor to continue. Do not construct one |
limit | 1–1000, default 200 |
Loop until nextCursor is null, then store syncedAt and pass it as the next
run's updatedSince. syncedAt is captured before the page is read, so a
product changed mid-walk lands in your next sync rather than being skipped.
version is an opaque content hash: equal version means the metadata is
byte-identical, so you can skip re-processing without diffing. It changes only
when the content actually changes, which is also why a re-sync of an unchanged
catalog returns nothing.
Only products visible to your shop are returned. Pages may therefore come back
shorter than limit (or empty) while the cursor still advances — that is normal,
keep following nextCursor and stop only when it is null.
Responds 503 while the feature is disabled for the deployment.
Notes on individual fields
aboutis pre-parsed into typed blocks of plain text rather than HTML, so it can be rendered withoutdangerouslySetInnerHTML. Inline emphasis from the original copy is not preserved.countryPolicydescribes where the product can be activated.{"kind":"worldwide"}is an explicit statement that there is no restriction — it is not the same as the field being absent, which means we hold no information.countryPolicyKindmirrors just the discriminator into the light half so a listing can badge "region locked" without carrying country arrays.region.excludedCountriescarries carve-outs (a "Europe except BY/RU" key). TreatcountryPolicyas authoritative for eligibility;regionis a display hint.isReleased: falsemeans the product is announced but not yet released — a pre-order. It is always accompanied by a futurereleaseDate. An absentisReleasedmeans "unknown", not "unreleased": top-ups, in-game currency and most already-released games carry no flag at all.media.imagesare always served from this API's own domain.media.videois an identifier, not a URL: build the embed yourself fromprovider+id.ageRating.descriptorsare PEGI content descriptors (violence,bad_language,gambling,online, …). An empty array means rated with nothing flagged; an absentageRatingmeans unrated or unknown.languagesare the languages the product itself supports, not the languages of this API's responses.
Fields are omitted rather than sent as null, and coverage varies widely by
product — treat every field as optional.
Form instructions
GET /api/v3/instructions/<id> returns a localizable rich-text (Markdown)
instruction that explains how to fill in a product's deliveryData form and
where to obtain the required values. <id> is a product id in the
gameSlug:categorySlug:productSlug format (the same id used by
GET /api/v3/products/<id>).
Check haveInstruction first. Every product in GET /api/v3/catalog and
GET /api/v3/products carries haveInstruction: boolean — request an
instruction only when it is true. The endpoint answers 404 for products
without one, and blind per-product probing has real cost on both sides (it was
a third of all API traffic before this flag existed).
GET /api/v3/instructions/highrise:gold_pack:999_v2
Authorization: Shop 111:aaabbbccc
Accept-Language: ru
{
"productId": "highrise:gold_pack:999_v2",
"level": "product",
"content": "## Как заполнить форму\n\nВведите **Username** из профиля игры…\n\n"
}
| Field | Description |
|---|---|
productId | The product id from the request |
level | How specific the returned instruction is: product, category, or game |
content | The instruction body in Markdown (may contain images as absolute URLs) |
The server returns the single most specific instruction available, checked in this order (a more specific level overrides a more general one):
- a general instruction on this product (
level: "product") - a general instruction on this product's category (
level: "category") - a general instruction on the game (
level: "game") - for activation-code products (
typecdkeyorgift_card), the default instruction shared by all of them — reported aslevel: "game"
A product may additionally carry a variant of its product-level instruction
that applies only to the way it is currently fulfilled. That variant wins over
the general product instruction when it exists, and is still reported as
level: "product" — which fulfilment path is in use is not part of the Shop
API contract and can change without notice.
If none of these exist, the endpoint responds with 404. It also responds
with 404 when the product does not exist or is not visible to your shop, and
with 400 when the id is not a valid gameSlug:categorySlug:productSlug.
content is localized via Accept-Language (see Localization):
when the header resolves to an enabled language and a translation exists for the
resolved instruction, the translated Markdown is returned; otherwise the default
content is returned. The instruction level is resolved first (by existence), and
the language is applied to that resolved instruction.
Localization
The Shop API supports response-name translation via the standard
Accept-Language request header. Translation applies to game, category,
and product name fields, to product description fields, and to the
metadata.about / metadata.activationNotice prose — IDs, slugs, icons,
prices, and delivery data never change.
Upstream coverage of translated metadata is thin and language-dependent; when no translation exists for the resolved language the original copy is returned.
Examples:
Accept-Language: pt-BR Accept-Language: pt-BR,pt;q=0.9,en;q=0.5 Accept-Language: ja, en;q=0.5
Resolution rules:
- The header is parsed by RFC 9110 quality values; equal weights keep request order.
- Each candidate code is matched against the active languages list. If
no exact match exists, the base subtag is tried (e.g.
pt-BR→pt). - If no enabled language matches, the response uses the original English names — IDs and other fields are unchanged.
Affected endpoints:
GET /api/v3/catalog GET /api/v3/games GET /api/v3/games/:id GET /api/v3/categories GET /api/v3/categories/:id GET /api/v3/products GET /api/v3/products/:id GET /api/v3/instructions/:id GET /api/v3/tags GET /api/v3/catalog/metadata GET /api/v3/hot-products
Orders
POST /api/v3/orders to create a new order.
POST /api/v3/orders
Authorization: Shop 111:aaabbbccc
Content-Type: application/json
{
"product": "highrise:gold_pack:999_v2",
"deliveryData": {
"username": "foobar"
},
"externalId": "f92051c0-3450-49ce-8454-2f69425fd824",
"amount": 100
}
{
"id": 42,
"product": "highrise:gold_pack:999_v2",
"externalId": "ORDER-12345",
"amount": 1,
"totalPrice": 9.99,
"status": "completed",
"deliveryData": { "username": "foobar" },
"cdKeys": [
{
"code": "ABCD-EFGH-IJKL",
"expireAt": "2026-09-18T00:00:00.000Z"
}
],
"createdAt": "2025-09-18T12:34:56.000Z",
"updatedAt": "2025-09-18T12:35:10.000Z"
}
Two optional request fields:
| Field | Description |
|---|---|
allowSplit | true lets the request be placed as two orders when one source cannot fill it — see Split orders. Default false |
maxTotalPrice | Reject the order with PRICE_CHANGED (409) if its live total, in USD, comes out above this. For a split request it is compared with the sum of the orders. Nothing is created and nothing is charged |
Every order object also carries split, which is null unless the order is
part of a split request.
Accounts
A product of type: "account" delivers a ready-made account of a service: the
buyer signs in with it, there is nothing to redeem. The completed order carries
accounts, one object per delivered account (so amount objects for
amount > 1), in the same order as the matching cdKeys entries:
{
"id": 43,
"product": "kling-ai:accounts:65-credits-1-month",
"externalId": "ORDER-12346",
"amount": 1,
"totalPrice": 0.12,
"status": "completed",
"deliveryData": {},
"accounts": [
{
"login": "[email protected]",
"password": "Abc123!",
"note": "Do not change the password: it voids the warranty"
}
],
"cdKeys": [{ "code": "[email protected]|Abc123!|Do not change the password: it voids the warranty" }],
"createdAt": "2026-11-07T12:34:56.000Z",
"updatedAt": "2026-11-07T12:34:57.000Z"
}
Every value is a string. These keys can appear today:
| Key | Meaning |
|---|---|
login | What the buyer signs in with: an e-mail, a username or a phone number |
password | The account password |
recoveryEmail | A mailbox tied to the account, for recovery or for sign-in codes |
note | Anything else the supplier sent, as free text: a 2FA secret, a profile name and PIN, a link |
expiry | How long the account is valid, as the supplier states it. Free text, not a date |
Only the keys that have a value are present. Show the buyer every key you receive, with the key as its label, including keys that are not in this table: a new key can be added without a new version, and dropping it may hide something the buyer needs to sign in.
accounts is null for every product type other than account, and for an
account order that is not completed.
Until the next version, an account order also carries the same data joined
with | into cdKeys[].code, as these products were delivered before 1.10.0.
That copy exists only so a shop that has not moved to accounts still shows
something. It will be removed: then cdKeys of an account order is null.
Idempotency (externalId)
externalId makes order creation safe to retry, for example after a timeout.
Before creating an order we look up your earlier orders with the same
externalId and compare the payload: product, amount and deliveryData.
Earlier order with this externalId | Same payload | Different payload |
|---|---|---|
| None | New order | New order |
pending, completed or partially_completed | That order is returned again. No new order and no second charge | 409 DUPLICATE_EXTERNAL_ID |
Only failed ones | New order | New order |
A failed order does not hold its externalId. Nothing was delivered and your
balance was restored, so re-sending the request is a real retry: it creates
and charges a new order. To stop an order from being retried after a failure,
use a fresh externalId for each purchase the customer makes.
Without externalId every request creates a new order.
Order creation fail
A failed order create returns status code 400, 409, 500 or 503 with the following response body.
type OrderErrorCode =
| "INVALID_CREDENTIALS"
| "INVALID_AMOUNT"
| "PRODUCT_NOT_FOUND"
| "PRODUCT_ALREADY_BOUGHT"
| "SERVER_ERROR"
| "UNKNOWN_ERROR"
| "PROVIDER_ERROR"
| "PROVIDER_CANCELLED"
| "PARTIAL_FULFILLMENT"
| "DELIVERY_UNCONFIRMED"
| "DUPLICATE_EXTERNAL_ID"
| "ROBLOX_NOT_IN_GAME"
| "ROBLOX_NEVER_PLAY"
| "ROBLOX_GAME_ERROR"
| "ROBLOX_SUPERPASS_UNAVAILABLE"
| "USER_REACH_LIMIT"
| "REGION_RESTRICTED"
| "NOT_ENOUGH_SHOP_BALANCE"
| "NOT_ENOUGH_STOCK"
| "PRICE_CHANGED"
| "NOT_ENOGHT_SHOP_BALANCE"; // @deprecated Use NOT_ENOUGH_SHOP_BALANCE
type OrderFailedResponse = {
errorCode: OrderErrorCode;
errorMessage: string; // Human-readable, derived from errorCode — do not parse
errorId?: string; // UUID v7 for tracking order execution errors
availableAmount?: number; // NOT_ENOUGH_STOCK: the amount that would go through right now
totalPrice?: number; // PRICE_CHANGED: what the order would have cost (USD)
};
Branch on errorCode, never on errorMessage. errorMessage is a fixed
English sentence per code (a few codes append the offending values, e.g. your
balance or the allowed amount range), it is not localized, and its wording may
change at any time. It never reproduces an upstream system's own text.
OrderErrorCode is one type for both channels, but not every code can reach
both. An order that is rejected while your request is still open answers on
that request; an order that is accepted and fails later reports through the
statusChange webhook. Codes marked "webhook only" below never appear in an
HTTP response, and DUPLICATE_EXTERNAL_ID is the reverse — it is a request
rejection and never arrives by webhook.
Contract change — accept two new
errorCodevalues.PROVIDER_CANCELLEDandDELIVERY_UNCONFIRMEDare new. Both can appear in thePOST /api/v3/ordersresponse and in thestatusChangewebhook. Clients that strictly validate the error enum must accept them first.They exist because a supplier that reports every failure with one opaque code makes
UNKNOWN_ERRORmean two different things — "the supplier flapped, retry" and "we cannot tell whether the goods landed". Branch as follows: treatPROVIDER_CANCELLEDlikePROVIDER_ERROR(retryable, not the customer's fault), and treatDELIVERY_UNCONFIRMEDas needing a human — the order was not charged, but re-ordering the same account risks a double delivery.Nothing else changes: existing codes keep their meanings, and a client that lumps unrecognised codes with
UNKNOWN_ERRORstays correct, just less precise.
Error Response Details
Status Codes:
400- Client errors (invalid input, insufficient balance, user limits)INVALID_CREDENTIALS,INVALID_AMOUNT,PRODUCT_NOT_FOUND,NOT_ENOUGH_SHOP_BALANCE,NOT_ENOUGH_STOCK,USER_REACH_LIMIT,REGION_RESTRICTED
409- ConflictDUPLICATE_EXTERNAL_ID— theexternalIdwas already used for a different order payloadPRICE_CHANGED— the live total is above themaxTotalPriceyou sent
500- Server errors (internal failures)SERVER_ERROR,UNKNOWN_ERROR,DELIVERY_UNCONFIRMED
503- Service temporarily unavailable (retry later)PROVIDER_ERROR,PROVIDER_CANCELLED,ROBLOX_SUPERPASS_UNAVAILABLE
What each code means:
| Code | Meaning | What to do |
|---|---|---|
INVALID_CREDENTIALS | The player identifier (UID, server, username) was rejected by the game or the provider | Ask the customer to correct the data and re-order |
INVALID_AMOUNT | The requested quantity or denomination was rejected | Re-read the product's amountType and re-order |
PRODUCT_NOT_FOUND | The product is unknown, disabled or out of stock upstream | Refresh the catalog; do not retry the same product |
PRODUCT_ALREADY_BOUGHT | The player already owns this one-time purchase (webhook only) | Nothing to retry |
USER_REACH_LIMIT | The player hit a purchase limit imposed by the game | Retry later |
REGION_RESTRICTED | The product cannot be delivered to this customer's game region or account country | Do not retry with the same account; check the product's regional availability |
NOT_ENOUGH_SHOP_BALANCE | Your shop balance cannot cover the order | Top up and re-order |
NOT_ENOUGH_STOCK | The product is on sale, but the requested amount is more than can be sourced right now. Nothing was created or charged | Order availableAmount or less (it is in the response when known), or retry later. With allowSplit the figure is what two orders could buy together |
PRICE_CHANGED | The live total came out above your maxTotalPrice (HTTP only). Nothing was created or charged; totalPrice is what it would have cost | Re-read the price, confirm with the customer, re-order |
PROVIDER_ERROR | The upstream provider is unavailable, misconfigured or out of stock, and no amount at all can be sourced — never the customer's fault | Retry later |
PROVIDER_CANCELLED | The order was cancelled upstream before delivery and was not charged. Distinct from PROVIDER_ERROR: the order was accepted and then taken back, not refused | Retry later |
DELIVERY_UNCONFIRMED | Nobody can say whether the goods were delivered. The order was not charged, but the target account may already have received them | Do not re-order the same account before support has checked; report the errorId |
ROBLOX_NOT_IN_GAME, ROBLOX_NEVER_PLAY, ROBLOX_GAME_ERROR | Roblox-specific delivery failures (webhook only) | See the Roblox section |
ROBLOX_SUPERPASS_UNAVAILABLE | No SuperPass worker is available to process the order | Retry later |
PARTIAL_FULFILLMENT | A bundle delivered fewer units than ordered (webhook only, with partially_completed) | Only the delivered units are charged |
DUPLICATE_EXTERNAL_ID | The externalId is already bound to a different order payload (HTTP only) | Use a fresh externalId, or re-send the identical payload to replay idempotently |
SERVER_ERROR | A fault on our side | Retry later; report with the errorId |
UNKNOWN_ERROR | The order failed and the cause could not be attributed | Report with the errorId |
errorCode on the order object.
Until now the cause of a failure travelled only on the statusChange webhook,
which meant a shop that missed one — a restart, a deploy, a brief outage — had
no second source: GET /orders/:id answered failed and nothing else. The
order object now carries errorCode too, so the cause is recoverable by
polling.
It is present only when we actually know the cause; a failure the supplier did
not explain still arrives without it, and the field is absent rather than
UNKNOWN_ERROR, so "we do not know" stays distinguishable from "the supplier
said UNKNOWN_ERROR". The webhook's own top-level errorCode is unchanged —
prefer it when you have it, and treat the order field as the fallback.
Error ID (errorId):
The errorId field is included for order execution errors (errors that occur during the order fulfillment process). It's a UUID v7 that can be used to correlate errors with server logs when contacting support. The errorId is present for:
INVALID_CREDENTIALS- When order execution fails due to invalid player credentialsUSER_REACH_LIMIT- When player has reached purchase limitREGION_RESTRICTED- When the provider rejected the delivery because of the customer's game region or account countryNOT_ENOUGH_SHOP_BALANCE- When shop balance is insufficientROBLOX_SUPERPASS_UNAVAILABLE- When SuperPass service has no available workers to process the orderPRODUCT_NOT_FOUND,INVALID_AMOUNT,PROVIDER_ERROR,PROVIDER_CANCELLED- When the upstream provider rejected the order and named the reasonDELIVERY_UNCONFIRMED- When the outcome of the delivery could not be established either wayUNKNOWN_ERROR- For other execution failures
Validation failures rejected before execution (a malformed body, an unknown
product, a DUPLICATE_EXTERNAL_ID conflict) carry no errorId.
Example error response:
{
"errorCode": "USER_REACH_LIMIT",
"errorMessage": "User reached a limit of purchase in game",
"errorId": "019473a2-7c3f-7000-8000-000000000001"
}
GET /api/v3/orders to get all your orders.
| Query param | Description |
|---|---|
| offset | Optional offset for list |
| limit | Optional limit for list |
GET /api/v3/orders?offset=20&limit=10
Authorization: Shop 111:aaabbbccc
[{
"id": 42,
"product": "highrise:gold_pack:999_v2",
"externalId": "ORDER-12345",
"amount": 1,
"totalPrice": 9.99,
"status": "completed",
"deliveryData": { "username": "foobar" },
"cdKeys": [
{
"code": "ABCD-EFGH-IJKL",
"expireAt": "2026-09-18T00:00:00.000Z"
}
],
"createdAt": "2025-09-18T12:34:56.000Z",
"updatedAt": "2025-09-18T12:35:10.000Z"
}]
GET /api/v3/orders/:id to get certain order.
GET /api/v3/orders/42
Authorization: Shop 111:aaabbbccc
{
"id": 42,
"product": "highrise:gold_pack:999_v2",
"externalId": "ORDER-12345",
"amount": 1,
"totalPrice": 9.99,
"status": "completed",
"deliveryData": { "username": "foobar" },
"cdKeys": [
{
"code": "ABCD-EFGH-IJKL",
"expireAt": "2026-09-18T00:00:00.000Z"
}
],
"createdAt": "2025-09-18T12:34:56.000Z",
"updatedAt": "2025-09-18T12:35:10.000Z"
}
Bundles & partial fulfillment
Bundles are not listed on
/api/v3yet. They are sold through/api/v1only. A bundle order placed there can still be read through/api/v3/orders.
A bundle is an ordinary catalog product whose purchase places several units
of one underlying component product (e.g. a "32000 UC" bundle that buys 4 × 8000
UC). You order it exactly like any other product — amount must be 1, and
its catalog price is the sum of those units.
Because each unit is fulfilled independently, a bundle order can be partially
fulfilled. Two extra fields appear on bundle orders (and are null on every
non-bundle order):
| Field | Description |
|---|---|
requestedQuantity | Units the bundle was meant to deliver (N) |
deliveredQuantity | Units actually delivered (M ≤ N) |
A new order status — partially_completed — is reported when 0 < M < N.
In that case totalPrice is reduced to the price of the delivered units only:
you are never charged for undelivered units — the difference is restored to
your balance automatically, exactly as for a failed order. For code-based
products, cdKeys then contains only the delivered keys.
Contract change — action required before selling bundles.
partially_completedis a new value ofstatus, andPARTIAL_FULFILLMENTis a newerrorCode. They can appear in thePOST /api/v3/ordersresponse,GET /api/v3/orders,GET /api/v3/orders/:id, and thestatusChangewebhook. Clients that strictly validate the status/error enums must accept these values first. Treatpartially_completedas terminal success for the delivered units, and usedeliveredQuantity/cdKeysfor the exact amount delivered. Only bundle orders ever carry this status — if you do not sell bundles, you will never receive it.
Split orders
Some products are sold by quantity and carried by more than one source, each
with its own price and its own limit on how much it can deliver right now. By
default a request goes whole to the cheapest source that can take all of it.
With "allowSplit": true the cheapest source is used for as much as it can
take and the remainder is ordered from the next one:
POST /api/v3/orders
Authorization: Shop 111:aaabbbccc
Content-Type: application/json
{
"product": "steam:balance:usd",
"deliveryData": { "account": "gabe" },
"externalId": "ORDER-12345",
"amount": 90,
"allowSplit": true,
"maxTotalPrice": 90.06
}
{
"id": 101,
"product": "steam:balance:usd",
"externalId": "ORDER-12345",
"amount": 83.81,
"totalPrice": 83.8017,
"status": "completed",
"deliveryData": { "account": "gabe" },
"split": { "groupId": 101, "index": 1, "count": 2, "requestedAmount": 90 },
"linkedOrders": [
{
"id": 102,
"product": "steam:balance:usd",
"externalId": "ORDER-12345",
"amount": 6.19,
"totalPrice": 6.2519,
"status": "completed",
"deliveryData": { "account": "gabe" },
"split": { "groupId": 101, "index": 2, "count": 2, "requestedAmount": 90 }
}
]
}
These are two ordinary orders, not one order in two pieces. Each has its
own id, amount, totalPrice and status, is listed by GET /api/v3/orders
like any other, and sends its own statusChange webhook. Each is charged
only if it is delivered, so there is no partial status to interpret: if order
101 completes and order 102 fails, your customer received 83.81, you were
charged for 83.81, and 6.19 is yours to re-order or refund. split is how you
tell that they answer one request:
| Field | Description |
|---|---|
split.groupId | Same on every order of the request — the id of the first one |
split.index / split.count | This order's position, and how many orders the request became (at most 2) |
split.requestedAmount | The amount you sent; the orders' amounts add up to it |
linkedOrders | The other orders of the group. Only on the POST /api/v3/orders response and on GET /api/v3/orders/<id> (asking for either order returns the first on top); not in lists and not in webhooks |
Rules worth knowing:
- A split happens only when it helps. If the cheapest source can take the
whole amount you get one ordinary order (
split: null), and likewise when a split would save less than half a percent. No part is smaller than 5% of the requested amount. externalIdis shared by both orders, and replaying the request (sameexternalId, same payload, with or withoutallowSplit) returns the same group with200— never a second purchase.GET /api/v3/orders/external/<id>returns both orders.- Your balance is checked against the sum before anything is created.
- Every source gets the same
deliveryData. A source whose form would not accept your payload is never used for a part. - Failures inside the request. If every order failed, the response is the
usual error body (as for a plain order). If at least one went through, the
response is
201with the orders, and an order that failed carries its ownerrorCode(anderrorId). When the first order fails because of the customer's data (INVALID_CREDENTIALS,REGION_RESTRICTED, …) the second is not sent at all — it would have failed for the same reason. - An order may still be
pendingin the response while the other is already final, exactly as two separate orders would be. - Bundles are never split.
If you do not send allowSplit, none of this concerns you.
Balance
GET /api/v2/shops/my/balance retrieve your current shop balance in USD.
GET /api/v2/shops/my/balance
Authorization: Shop 111:aaabbbccc
{
"balance": 123.45
}
The balance represents the available funds in your shop account (deposits minus usage). All orders placed through the API will deduct from this balance. If your balance is insufficient for an order, the API will return a NOT_ENOUGH_SHOP_BALANCE error.
Hot Products
GET /api/v3/hot-products retrieve the curated list of hot (featured) products, ordered by the admin-defined priority.
GET /api/v3/hot-products
Authorization: Shop 111:aaabbbccc
[
{
"id": "highrise:gold_pack:999_v2",
"name": "Gold Pack — 999",
"icon": "https://cdn.example.com/products/gold_999.png",
"price": 9.99,
"deliveryData": [
{
"type": "text",
"id": "username",
"label": "Username",
"required": true,
"regex": "\\S+"
}
],
"type": "topup",
"amountType": { "type": "one" },
"categoryName": "Gold Packs",
"gameName": "Highrise",
"gameIcon": "https://cdn.example.com/icons/highrise.png"
}
]
Each product in the response is identical to the Product type described above, with three additional fields:
| Field | Description |
|---|---|
categoryName | Name of the product's category |
gameName | Name of the product's game |
gameIcon | URL for the game's icon, or null |
Products are returned in the order set by the admin. Products that are temporarily unavailable are silently omitted from the response.
Utils
This section describe special game-specific utils API.
Roblox
For products Robux via pass you need to provide pass id from user. To make it easy to help user get this id you can use next API.
You need to get username from user and then fetch user id, fetch user places and show it to customer. When customer choose place you need to fetch passes for this place and show it to customer.
And then you just need to create an order with Nexus API providing choosen universe_id and price.
Get user's id
GET /api/v3/utils/roblox/get-user/<username>
{
"id": "5642960000",
"username": "someuser"
}
Get user's places
GET /api/v3/utils/roblox/get-places/<user_id>
[
{
"universe_id": "9063010000",
"name": "Some place"
},
{
"universe_id": "5728448000",
"name": "someuser's Place",
"icon": "https://t4.rbxcdn.com/180DAY-545d29ed783b5cb8d3b8cd65ff22ffff"
}
]
Response can have not an icon field.
Get user's passes in certain place
GET /api/v3/utils/roblox/get-passes/<place_id>
{
"passes": [
{
"id": "1580665000",
"name": "pass 32323",
"is_for_sale": true,
"price": 29
},
{
"id": "1581555000",
"name": "pass123",
"is_for_sale": true,
"price": 72
},
{
"id": "1565427000",
"name": "Pass 1",
"is_for_sale": false,
"price": 0
}
]
}
Get games which support SuperPasses purchase
SuperPass is available on
/api/v1only for now.roblox:passes:<id>products are not on v3:GET /api/v3/products/roblox:passes:<id>answers404andPOST /api/v3/ordersanswersPRODUCT_NOT_FOUND. Use the/api/v1paths of this flow for both the read and the order.
GET /api/v1/utils/roblox/super-games
[
{
"id": "69203a7f0fdd2147559b5b97",
"name": "[🔥UPD] Гиперстрельба",
"name_en": "[🔥UPD] Hypershot",
"uri": "https://www.roblox.com/games/17516596118",
"image_uri": "https://storage.yandexcloud.net/roblox/1763719805017818977_6376.jpg",
"description": "✨Добро пожаловать в Hypershot!✨\nПрыгайте в нашу интенсивную и быструю стрельбу от первого лица, где мастерство, стиль и хаос преобладают!\n\n🏆Доминируйте в интенсивных классических режимах игры, таких как командный матч смерти, захват флага, бесплатная игра для всех и игра с оружием (Арсенал).\n💥 Объединитесь с друзьями, чтобы бросить вызов соперникам в дуэлях 1 на 1 или 2 на 2\n👑Поднимитесь по рангам, чтобы достичь таблицы лидеров!\n🌀Овладейте своими способностями, чтобы перехитрить и перехитрить своих врагов!\n🔥 Зарабатывайте монеты и драгоценные камни, чтобы разблокировать новое оружие, скины, способности и многое другое!\n🎮Доступно на ПК, телефонах, планшетах и консолях\n\n❤️Сделано с любовью Frosted Studios\n\n⚠️МОШЕННИЧЕСТВО В ЛЮБОЙ ФОРМЕ ПРИВЕДЕТ К ЗАПРЕТУ НА ВСЕ УЧЕТНЫЕ ЗАПИСИ⚠️",
"rate": 95,
"online": 25792,
"in_game": false,
"market_enabled": false,
"superpass_enabled": true,
"tags": null,
"market_orders": 0,
"superpass_orders": 0,
"universe_id": "5995470825"
},
{
"id": "692041ef0fdd2147559b5cc9",
"name": "[👮] Диспетч: Полицейский симулятор",
"name_en": "[👮] Dispatch: Police Simulator",
"uri": "https://www.roblox.com/games/98100179608264",
"image_uri": "https://storage.yandexcloud.net/roblox/1763721708509406099_19076.jpg",
"description": "🚨 Полицейский симулятор:\nСтаньте настоящим офицером в высокооплачиваемом полицейском симуляторе на базе ИИ. Отвечайте на динамичные чрезвычайные ситуации, преследуйте бегущих подозреваемых, рассеивайте напряженные ситуации и восстанавливайте порядок - все это в живом, реактивном городе, которому нужна ваша помощь.\n\n👮 Как играть:\n- Присоединяйтесь к игре и выберите свое снаряжение и оружие из шкафчика.\nДождитесь появления активных вызовов в меню и ответьте на них.\n- Подойдите к месту происшествия и попробуйте справиться с подозреваемыми умным способом.\n\n⭐ Ожидайте новых обновлений каждую неделю!\n\n🎉 Присоединяйтесь к нашей группе и добавьте \"Нравится\" к игре, чтобы разблокировать $350 + 100XP.\n",
"rate": 92,
"online": 1027,
"in_game": true,
"market_enabled": false,
"superpass_enabled": true,
"tags": null,
"market_orders": 0,
"superpass_orders": 0,
"universe_id": "8104504677"
}
]
| Field | Description |
|---|---|
| rate | 0-100 rating |
| in_game | whether client is required to join the game during the trade for this game rules |
| superpass_enabled | whether the game is available for superpass method |
| superpass_orders | current orders count for superpass method in this game |
Get SuperPasses for certain game
GET /api/v1/utils/roblox/super-games/<game-id>/passes
[
{
"id": "roblox:passes:692041450fdd2147559b5ca8",
"amountType": {
"type": "one"
},
"deliveryData": [
{
"type": "text",
"id": "username",
"label": "Username",
"required": true
}
],
"icon": "https://storage.yandexcloud.net/roblox/1763721538644369518_50793.jpg",
"name": "Machine gems",
"type": "topup",
"price": 11.3943
},
{
"id": "roblox:passes:692041880fdd2147559b5cb6",
"amountType": {
"type": "one"
},
"deliveryData": [
{
"type": "text",
"id": "username",
"label": "Username",
"required": true
}
],
"icon": "https://storage.yandexcloud.net/roblox/1763721606119493422_41507.jpg",
"name": "Sparkling Gems",
"type": "topup",
"price": 22.7943
}
]
If you try to use default
GET /api/v1/products?categoryId=roblox:passesit will return empty array.But
GET /api/v1/products/roblox:passes:692041880fdd2147559b5cb6still works fine and return product data.
Important: For superpass orders, the buyer may be required to join a Roblox game and connect to an employee for the trade. When this is needed, your webhook URL will receive a
robloxSuperpassInGameevent with the employee's Roblox username and profile link. Show this information to the buyer so they can join the employee's game room.
Important: Products under
roblox:robux-login(Robux credited by signing into the buyer's own account) cannot complete without buyer action. Right after the order is created your webhook URL receives arobloxLogpassVerificationevent carrying averification.url. The buyer must open that page and sign into their Roblox account (including 2FA) there. Until they do, the order stayspending— it is never failed by timeout — so an order whose link was never shown to the buyer hangs indefinitely. If you missed the webhook, read the link back fromGET /api/v3/utils/roblox/logpass-order/:orderId/verification.The
passwordandrecovery_codedelivery-data fields aretype: "secret". They are optional (omit them and the buyer types them on that page instead). If you do collect them: render them masked, send them over HTTPS, and do not store them — we forward them to the supplier and erase our copy as soon as the order is accepted, and every place we echo an order back to you shows them as"[hidden]".
Check SuperPass availability
GET /api/v1/utils/roblox/superpass-stock
Returns whether superpass orders can currently be placed.
{
"available": true
}
When available is false, any attempt to create a superpass order will fail with 503 ROBLOX_SUPERPASS_UNAVAILABLE. Wait and retry later.
Errors of the Roblox pass and SuperPass lookups
get-passes, super-games, super-games/<game-id>/passes and superpass-stock read live
upstream data. When the upstream does not answer they respond:
| Status | Body | Meaning |
|---|---|---|
503 | { "error": "Service temporarily unavailable" } | Upstream did not answer — not your fault, retry later |
For superpass-stock treat a 503 like available: false: do not offer superpass
purchases until a retry succeeds.
Get SuperPass Order Employee Data
GET /api/v1/utils/roblox/superpass-order/:orderId/employee
Fallback endpoint to retrieve employee data for a superpass order if the robloxSuperpassInGame webhook was missed. Only works for superpass orders in pending status.
{
"employee": {
"username": "worker123",
"profileUrl": "https://www.roblox.com/users/5087011877/profile"
}
}
Returns 404 if order not found, not a superpass order, not in pending status, or employee data is not yet available.
Get Logpass Order Verification URL
GET /api/v3/utils/roblox/logpass-order/:orderId/verification
Fallback endpoint to retrieve the verification page for a roblox:robux-login order if the
robloxLogpassVerification webhook was missed. Only works for logpass orders in pending
status.
{
"verification": {
"url": "https://.../v/KldB0zRd"
}
}
Returns 404 if the order is not found, is not a logpass order, is not in pending status, or
the supplier has not issued the link yet (it is issued within seconds of order creation, but
not always before the create call returns — retry shortly rather than creating a new order).
PUBG
Check user ID
You can check if user with certain id exists in game using next endpoint
GET /api/v3/utils/pubg/player/<id>
If user exists you will receive next response
{
"username": "someuser"
}
Otherwise you will receive
{
"username": null
}
| Status | Body | Meaning |
|---|---|---|
200 | { "username": "someuser" } | The player exists |
200 | { "username": null } | No such player |
400 | Invalid id | The id is not a positive integer |
503 | { "error": "Service temporarily unavailable" } | The check could not be done right now, which says nothing about the player. Retry later, and do not treat it as "no such player" |
Free Fire
Check player ID and account market
Free Fire is sold as a separate product line per market — the CIS line, the
Brazil line and so on are different categories (free-fire:diamonds-cis,
free-fire:packs-cis, free-fire:pass-cis, …). The market is a property of the
player's account, never of the delivery form: the form asks for the player
id and nothing else. An order whose product belongs to a different market than
the account can be rejected at delivery, so resolve the market first where it
can be resolved and show the buyer only the nominals they can actually receive.
GET /api/v3/utils/fireloot/player/<uid>, where <uid> is the numeric Free Fire player id (1 to 20 digits)
If the player exists you will receive
{
"username": "someuser",
"nameChecked": true,
"region": "CIS"
}
| Field | Description |
|---|---|
username | The player's in-game nickname, or null when the game did not answer it |
nameChecked | false means the nickname is unknown — the id is still valid, but do not show a name |
region | The market the account belongs to (see the table below), or null when it cannot be determined |
Only CIS can be determined today. For any other account the lookup
confirms the id exists and returns the nickname, but region is null: the
supplier's check does not tell the other markets apart. Treat null as "market
unknown" — neither block the buyer nor pick a line for them from it; let the
buyer choose their market. Earlier versions wrongly reported every non-CIS
account as BANGLADESH.
region is the market code as the supplier spells it. The catalog spells the
same market as a slug suffix on the category:
region | Category suffix |
|---|---|
CIS | -cis |
BANGLADESH | -bangladesh |
BRAZIL | -brazil |
EU | -europe |
INDONESIA | -indonesia |
LATAM | -latam |
MENA | -mena |
PAKISTAN | -pakistan |
SG | -singapore |
TAIWAN | -taiwan |
VIETNAM | -vietnam |
New markets can appear without notice, so never hard-code this list: a
region you do not recognise means "no matching line for this player", not an error. Only
the markets present in your catalog response are on sale — a market missing
from the catalog cannot be served even when the lookup returns it.
If the player id does not exist you will receive 404
{
"error": "Player not found",
"code": "invalid_uid"
}
If the account exists but no market on sale accepts it you will receive 404
with a different code (rare: a non-CIS account normally comes back as found,
with region: null)
{
"error": "Player's game region is not supported",
"code": "region_unsupported"
}
region_unsupported is not a bad id — the player is real and other Free Fire
lines in the catalog may still be able to serve them.
Other responses:
| Status | Body | Meaning |
|---|---|---|
400 | Invalid id (plain text) | The id is not a 1 to 20 digit number |
502 | { "error": "Player lookup failed" } | The lookup itself failed — the verdict is unknown, retry rather than block the buyer |
503 | { "error": "Lookup is not available" } | Free Fire lookup is not configured on this installation |
A verdict is cached for 5 minutes per player id, so a nickname changed in-game can take that long to show up.
Genshin
Check user ID for server
You can check if user with certain id exists in game using next endpoint
GET /api/v3/utils/genshin/player/<server>/<id>, where server can be one of os_usa, os_euro, os_asia, os_cht
If user exists you will receive next response
{
"username": "someuser"
}
Otherwise you will receive
{
"playerName": null
}
Steam
Check account login
A Steam balance top-up (steam:balance:usd) is credited to the buyer's
Steam account login — the name they sign in to Steam with. A login that
does not exist fails the order with INVALID_CREDENTIALS (no money is taken),
and that is by far the most common reason Steam top-ups fail. Check the login
while the buyer is still at checkout:
GET /api/v3/utils/steam/account/<login>, where <login> is the account login
(URL-encode it; surrounding whitespace is ignored)
If the account exists you will receive 200
{
"exists": true
}
If no Steam account has this login you will receive 404
{
"error": "Account not found",
"code": "not_found"
}
Ask the buyer to re-enter the login. An order for it would fail with
INVALID_CREDENTIALS.
What the check accepts:
- Only the account login. The profile nickname, a SteamID64
(
7656119…), a profile link and an e-mail address are not logins, and the check will not find them. - Letter case does not matter.
GabeNandgabenare the same account.
The check says whether the account exists, nothing more: it returns no nickname, and it cannot tell whether the account can actually receive a top-up (region, restrictions, limited accounts). An order for an existing login can still fail for those reasons.
Other responses:
| Status | Body | Meaning |
|---|---|---|
400 | Invalid login (plain text) | The login is empty or longer than 64 characters |
502 | { "error": "Account lookup failed" } | The check itself failed — the verdict is unknown; retry, or let the buyer continue rather than block them |
A check takes about a second. A verdict is cached for 5 minutes per login.
Webhooks
Nexus sends webhooks to notify your system about asynchronous events (e.g. order status changes). Configure your webhook URL in the Control Panel.
Every webhook request:
- Uses the
POSTmethod - Has
Content-Type: application/json - Includes a cryptographic signature in the
Signheader - Contains a JSON body with the event payload
You must verify the signature before trusting the payload.
Signature Verification
The Sign header is an HMAC-SHA256 digest (hex-encoded) of the exact raw
request body using your shop token as the secret.
Verification steps:
- Read the raw request body as a string (do not JSON.parse before hashing).
- Compute
HMAC_SHA256(body, shopToken)and hex-encode the result. - Compare it with the value of the
Signheader. - Reject the request (e.g. HTTP 401) if the signatures differ or the header is missing.
TypeScript example:
import { createHmac } from "node:crypto";
async function verifyWebhook(
req: Request,
shopToken: string,
): Promise<boolean> {
const body = await req.text();
const receivedSign = c.req.header("sign");
if (!receivedSign) return false;
const sign = createHmac("sha256", shopToken).update(body).digest("hex");
return sign === receivedSign;
}
Recommendations:
- Respond with
200quickly (within 5s). Perform longer processing asynchronously. - Log signature failures with correlation identifiers but never log the raw token.
Event Payload Schema
type OrderStatus =
| "pending"
| "completed"
| "failed"
| "partially_completed"; // bundle orders only — see "Bundles & partial fulfillment"
type Order = {
id: number; // Order ID in Nexus
product: string; // Product ID
externalId: string | null; // Your internal order ID (if supplied)
amount: number; // Quantity purchased (always 1 for a bundle order)
requestedQuantity: number | null; // Bundle: units intended (N); null for non-bundle orders
deliveredQuantity: number | null; // Bundle: units delivered (M ≤ N); null for non-bundle orders
split: OrderSplit | null; // Set when the order is part of a split request — see "Split orders"
totalPrice: number; // Total price paid (USD); for partially_completed, only delivered units
status: OrderStatus; // Current status
deliveryData: Record<string, string | number>; // Provided delivery info
cdKeys: CDKey[] | null; // CD keys array if product type is `cdkey` or `gift_card` (and, until the next version, a joined copy for `account`)
accounts: AccountData[] | null; // Delivered accounts if product type is `account`, otherwise null. See "Accounts"
errorCode?: OrderErrorCode; // Why a `failed` order failed, when we know. See below.
createdAt: string; // ISO 8601 timestamp
updatedAt: string; // ISO 8601 timestamp
};
// Codes a webhook can carry. Meanings are tabulated under "Order creation
// fail"; DUPLICATE_EXTERNAL_ID is the one code that never arrives this way.
type OrderErrorCode =
| "INVALID_CREDENTIALS"
| "INVALID_AMOUNT"
| "PRODUCT_NOT_FOUND"
| "PRODUCT_ALREADY_BOUGHT"
| "SERVER_ERROR"
| "UNKNOWN_ERROR"
| "PROVIDER_ERROR"
| "PROVIDER_CANCELLED"
| "DELIVERY_UNCONFIRMED"
| "USER_REACH_LIMIT"
| "NOT_ENOUGH_SHOP_BALANCE"
| "NOT_ENOUGH_STOCK" // Rare on this channel: supply ran out between acceptance and delivery
| "ROBLOX_NOT_IN_GAME"
| "ROBLOX_NEVER_PLAY"
| "ROBLOX_GAME_ERROR"
| "PARTIAL_FULFILLMENT"; // accompanies a partially_completed bundle order
type OrderSplit = {
groupId: number; // Same on every order of one split request: the id of the first
index: number; // 1-based position of this order in the group
count: number; // How many orders the request became
requestedAmount: number; // The amount of the original request
};
type CDKey = {
code: string; // Redeemable key
cardNo?: string; // Gift card / card number (if applicable)
url?: string; // Redemption URL (if applicable)
expireAt?: string; // ISO 8601 expiry (if applicable)
};
// One delivered account of an `account` product. Open-ended: show every key.
type AccountData = {
login?: string; // Login: e-mail, username or phone
password?: string;
recoveryEmail?: string; // Mailbox tied to the account (recovery, sign-in codes)
note?: string; // Anything else the supplier sent: 2FA secret, profile, PIN, link
expiry?: string; // Validity as the supplier states it — free text, not a date
[key: string]: string | undefined;
};
type WebhookPayload =
| {
type: "statusChange";
order: Order;
errorCode?: OrderErrorCode; // Present if order.status is 'failed' or 'partially_completed'
errorId?: string; // UUID v7 for tracking (present for execution errors)
}
| {
type: "robloxSuperpassInGame";
order: Order; // order.status will be "pending"
employee: {
username: string; // Roblox username of the employee
profileUrl: string; // Roblox profile URL of the employee
};
}
| {
type: "robloxLogpassVerification";
order: Order; // order.status will be "pending"
verification: {
url: string; // Page the buyer must open to sign in and finish the purchase
};
};
Example JSON Body
Successful Order
{
"type": "statusChange",
"order": {
"id": 42,
"product": "highrise:gold_pack:999_v2",
"externalId": "ORDER-12345",
"amount": 1,
"totalPrice": 9.99,
"status": "completed",
"deliveryData": { "username": "foobar" },
"cdKeys": [
{
"code": "ABCD-EFGH-IJKL",
"expireAt": "2026-09-18T00:00:00.000Z"
}
],
"createdAt": "2025-09-18T12:34:56.000Z",
"updatedAt": "2025-09-18T12:35:10.000Z"
}
}
Failed Order
{
"type": "statusChange",
"order": {
"id": 43,
"product": "highrise:gold_pack:999_v2",
"externalId": "ORDER-12346",
"amount": 1,
"totalPrice": 9.99,
"status": "failed",
"deliveryData": { "username": "invalid_user" },
"cdKeys": null,
"createdAt": "2025-09-18T12:40:00.000Z",
"updatedAt": "2025-09-18T12:40:05.000Z"
},
"errorCode": "INVALID_CREDENTIALS",
"errorId": "019473a2-7c3f-7000-8000-000000000001"
}
Partially Completed Order (bundle)
{
"type": "statusChange",
"order": {
"id": 45,
"product": "pubg-mobile:uc:32000-bundle",
"externalId": "ORDER-12348",
"amount": 1,
"requestedQuantity": 4,
"deliveredQuantity": 3,
"totalPrice": 7.50,
"status": "partially_completed",
"deliveryData": { "player_id": "5123456789" },
"cdKeys": null,
"createdAt": "2025-09-18T12:50:00.000Z",
"updatedAt": "2025-09-18T12:50:08.000Z"
},
"errorCode": "PARTIAL_FULFILLMENT"
}
3 of the 4 units were delivered. totalPrice reflects only those 3 delivered
units, and the undelivered unit's cost was restored to your shop balance
automatically. For a code-based bundle, cdKeys would contain exactly the 3
delivered keys.
Roblox SuperPass In-Game Action
{
"type": "robloxSuperpassInGame",
"order": {
"id": 44,
"product": "roblox:passes:682387148499439cf26703a3",
"externalId": "ORDER-12347",
"amount": 1,
"totalPrice": 11.39,
"status": "pending",
"deliveryData": { "username": "CoolPlayer" },
"cdKeys": null,
"createdAt": "2025-09-18T12:34:56.000Z",
"updatedAt": "2025-09-18T12:34:56.000Z"
},
"employee": {
"username": "worker123",
"profileUrl": "https://www.roblox.com/users/5087011877/profile"
}
}
This webhook is sent when a Roblox superpass order requires the buyer to join a game for the trade. The buyer should open the employee's profile and join their game room to complete the exchange.
order.statuswill be"pending"— the order is not yet complete- Display the employee's
usernameandprofileUrlto the buyer - The final result (
completed/failed) will arrive later via a regularstatusChangewebhook
Roblox Logpass Verification
{
"type": "robloxLogpassVerification",
"order": {
"id": 46,
"product": "roblox:robux-login:1000",
"externalId": "ORDER-12349",
"amount": 1,
"totalPrice": 9.75,
"status": "pending",
"deliveryData": { "username": "CoolPlayer", "password": "[hidden]" },
"cdKeys": null,
"createdAt": "2026-08-29T12:34:56.000Z",
"updatedAt": "2026-08-29T12:34:56.000Z"
},
"verification": {
"url": "https://.../v/KldB0zRd"
}
}
This webhook is sent when a Roblox logpass order needs the buyer to sign into their own Roblox account. The order cannot progress until they do.
order.statuswill be"pending"— the order is not yet complete- Send the buyer to
verification.url; they sign in and pass 2FA on that page - There is no timeout: an order whose buyer never completes verification stays
pendingindefinitely, so treat a missed delivery of this webhook as a stuck order and read the link back fromGET /api/v3/utils/roblox/logpass-order/:orderId/verification - Any credential fields in
order.deliveryDataread"[hidden]"— we never echo them back - The final result (
completed/failed) arrives later via a regularstatusChangewebhook
Security Best Practices
- Enforce HTTPS and reject plain HTTP.
- Use a distinct, unguessable webhook path (e.g.
/webhooks/nexus/<uuid>). - Validate
Content-Typeisapplication/json. - Rate-limit the endpoint to mitigate abuse.
- Store only required fields; avoid persisting entire raw payloads unless needed for audit.
Deprecated: API v1
/api/v1 is deprecated. It still works, but do not start a new integration on
it. No removal date has been set.
Authentication, request and response shapes, error codes, the order lifecycle
and webhooks are the same on both versions, so everything above applies to v1
with /api/v1 in place of /api/v3, apart from these differences:
/api/v3 | /api/v1 (deprecated) | |
|---|---|---|
| Form and field ids | Fixed for the product; the same field has the same id across a game | May change between requests |
| Name, icon, type | Change only when the product does | May change between requests |
| Duplicate products | Listed once | The same product may be listed under several ids |
| Hot products | GET /api/v3/hot-products | GET /api/v2/shops/my/hot-products |
| Roblox SuperPass | Not available yet | Available |
| Bundles | Not listed yet | Listed |
| Balance | GET /api/v2/shops/my/balance | GET /api/v2/shops/my/balance |
Migrating from v1 to v3
Your token stays the same. Change the /api/v1 prefix to /api/v3 for the
catalog, orders and utils, then check these points, because a v3 product can
differ from what you stored from v1:
- Re-sync your catalog from
GET /api/v3/catalog. Most product ids are the same as on v1. Ids that were duplicates of another product do not exist on v3, so match the products you sell by id and review the ones that are gone. - Build checkout forms from the v3
deliveryDataof each product. Field ids can differ from the ones v1 shows for the same product (for example,player_idinstead ofuserId). If your code or database holds field ids from v1, update them: an order with v1 field ids is rejected on v3. - Create orders with
POST /api/v3/orders, using v3 product ids and v3 forms. - Read the featured list from
GET /api/v3/hot-products, if you use it. - Keep Roblox SuperPass and bundles on
/api/v1, if you sell them. They are not on v3 yet.
There is nothing to do for existing orders, webhooks and balance. Both versions
work with the same orders: GET /api/v3/orders/<id> returns an order placed
through v1 and the other way round, webhooks keep their format, and externalId
idempotency spans both versions.
Do not mix versions within one purchase: a product id and form read from one version must be ordered through the same version.
Sandbox
Nexus Sandbox is a separate environment for integrating your shop with the API.
It uses virtual USD and simulated deliveries. No purchase reaches a real
provider, credits a game account, or produces a redeemable code. Obtain the
sandbox credentials from your Nexus contact. The portal and API share
https://nexus-sandbox.amadeustech.dev (API prefix: /api/v3);
availability depends on the sandbox being provisioned.
Connect your shop
- Sign in to the sandbox portal with your sandbox login and password. The portal displays Nexus Sandbox. In My shops, copy the shop token and configure a webhook URL belonging to your test integration.
- Set a separate API base URL and
Authorization: Shop <id:secret>token in your application. Use separate local storage for sandbox orders and balances. Production credentials, sessions and funds are not shared with this environment. - Check
GET /api/v3/sandbox. It requires no token and returns{"mode":"sandbox","realMoney":false,"providers":["test"]}. Shop API responses includeX-Nexus-Environment: sandbox. - Read
/api/v3/games, categories and products using the usual API described below. Render each product'sdeliveryDataform and honor itsamountType. Use fictitious account identifiers: the simulator logs the supplied fields.
Virtual balance
A shop provisioned with the sandbox onboarding command starts with
1,000 test USD and a default
3% commission. Read GET /api/v3/sandbox/balance with shop authorization:
{ "sandbox": true, "balance": 1000 }
Use +1,000 test USD in My shops or POST /api/v3/sandbox/topup with the
same token (no request body required). A successful top-up returns HTTP 201:
{ "balance": 2000, "credited": 1000, "sandbox": true }
Self-service top-ups add 1,000 USD at a time, at most once per minute and only
if the resulting available balance is at most 10,000 USD. A top-up within a
minute of shop creation or the previous deposit returns 429; exceeding the
balance cap returns 409. Both return {"error":"..."}. No payment is needed.
These endpoints exist only in sandbox; they do not change your real balance.
Purchases apply the normal shop commission. Completed and pending orders
consume available funds; failed orders release their reservation. Always use
the API's actual product prices and order totalPrice. Top-ups preserve order
history and externalId idempotency; they do not reset the shop.
Predictable purchase scenarios
The sandbox exposes two synthetic services:
| Service | Categories | Behavior |
|---|---|---|
test-1 | test-1:det-topup, test-1:det-codes, test-1:det-accounts | Result chosen with deliveryData.outcome |
test-2 | test-2:rnd-topup, test-2:rnd-codes, test-2:rnd-accounts | Random success, failure or delayed result |
Each category contains p1 through p5; for example,
test-1:det-topup:p1 and test-1:det-codes:p1. There are 30 products in total.
p1 and p2 accept only amount 1. The other products exercise multiple-amount
rules: p3 accepts 1–100; p4 accepts 10–1000 in steps of 10; p5 accepts
1–10 in steps of 1. Check the returned amountType when constructing requests.
Set account to any fictitious value. For test-1, select one of these
outcome values (HTTP statuses refer to a new, valid order):
| Outcome | Initial response | Final result |
|---|---|---|
success | 201, status: completed | Virtual purchase succeeds |
fail | 503, errorCode: PROVIDER_ERROR | Failed; no funds consumed |
fail_credentials | 400, errorCode: INVALID_CREDENTIALS | Failed; no funds consumed |
pending_success | 201, status: pending | Completed after about 15 seconds |
pending_fail | 201, status: pending | Failed after about 15 seconds; funds released |
The operator can configure the pending delay. Restarting the sandbox API does
not discard pending orders: they are recovered from the database. Cdkey products
return synthetic TEST-... codes when completed. They have no redemption value.
Products in the accounts categories have type: "account" and return one
synthetic account per unit in accounts (login, password and a note),
plus the same data joined into cdKeys[].code as described in
Accounts. The account does not exist anywhere: signing in with it
will not work.
export NEXUS_SANDBOX_API='https://nexus-sandbox.amadeustech.dev'
export NEXUS_SANDBOX_TOKEN='<sandbox-shop-id>:<sandbox-secret>'
curl --fail-with-body "$NEXUS_SANDBOX_API/api/v3/orders" \
-H "Authorization: Shop $NEXUS_SANDBOX_TOKEN" \
-H 'Content-Type: application/json' \
--data '{
"product": "test-1:det-topup:p1",
"amount": 1,
"externalId": "sandbox-example-001",
"deliveryData": { "account": "demo-account", "outcome": "pending_success" }
}'
Repeat the identical request to check idempotency: the same order is returned
with HTTP 200 and no second charge. Test this with an outcome that does not
fail (success or pending_success): a failed order does not hold its
externalId, so repeating a fail or pending_fail request creates a new
order. Use a new externalId for each different scenario; reusing one with
different order data while its order is live returns 409. Poll
GET /api/v3/orders/<id> or
GET /api/v3/orders/external/<externalId> using your token. Orders belonging
to another shop are not accessible. The standard order endpoint rate limit
also applies (currently 30 order requests per minute per client IP).
Webhooks and lookup helpers
Sandbox sends the normal statusChange webhooks directly to your configured
test receiver, including deferred completions and failures. Payload and Sign
verification are the same as in Webhooks: HMAC-SHA256 of the raw
body using the complete sandbox id:secret token. Keep your test receiver
separate from production processing, return a successful response promptly,
and handle duplicates. Localhost/private receiver URLs are rejected by Nexus;
use a public HTTPS endpoint or your own tunnel for local development.
Lookup endpoints return fixtures, without contacting the real game/provider:
| Endpoint | Sandbox fixtures |
|---|---|
/utils/pubg/player/:id | Positive ID → Sandbox-<id>; 1 → null username; 2 → HTTP 503 |
/utils/fireloot/player/:uid | Numeric UID → simulated CIS player; 0 → 404 not_found; 1 → 404 region_unsupported; 2 → 502 |
/utils/genshin/player/:server/:player | Valid server and positive player → simulated username; player 1 → null |
/utils/steam/account/:login | Any login → {"exists": true}; nosuchlogin → 404 not_found; lookupfailed → 502 |
/utils/roblox/get-user/:username | Fixed user ID 10000001 and supplied username |
/utils/roblox/get-places/:id | One simulated place |
/utils/roblox/get-passes/:id | One purchasable and one unavailable simulated pass |
All paths in this table start with /api/v3 and require shop authorization.
Superpass listings are empty, superpass stock is unavailable, and employee /
logpass verification lookups return 404. Real provider-specific delivery flows
and the production catalog are not simulated in this version.
Before switching your integration to production, check success and both failure
paths, delayed outcomes, webhook signatures/duplicates, invalid form data,
amount validation, insufficient virtual funds, and repeated externalId.
Production onboarding supplies a separate URL/token and real funding; no
sandbox orders, codes or balances are transferred.
For AI agents & programmatic access
Download this document as raw Markdown, or request GET /docs/api with the header Accept: text/markdown to receive the same source.