Nexus Shop API/api/v3v1.10.3
Base URLhttps://nexus-sandbox.amadeustech.dev

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, type and deliveryData form 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 as GET /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 accounts array: one object per delivered account, with each piece of data under its own key (login, password, and when the supplier gives them recoveryEmail, note, expiry). See Accounts;
  • accounts is null for every other product type. cdKeys works as before for cdkey and gift_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:

RequestBeforeAfter
GET /api/v3/catalog, median1.2 s0.66 s
GET /api/v3/catalog, p952.4 s1.9 s
GET /api/v3/products, p951.1 s14 ms
v1 games listing, median776 ms2 ms
v1 catalog listing, p951.3 s0.9 s
POST /api/v3/orders, p952.9 s2.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_ERROR instead of UNKNOWN_ERROR;
  • the Roblox pass lookups answer 503 instead of a bare 500 while 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 401 with a reason. A missing, malformed or non-matching shop token used to get a bare 403 Forbidden text. It now gets 401, a WWW-Authenticate: Shop header 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 status 403, also accept 401.
  • A disabled shop still gets 403, now as JSON with "errorCode": "SHOP_DISABLED".
  • A body that is not valid JSON is a 400, not a 500. 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> carries availableAmount when 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=90 adds a quote: what that amount costs you, how much of it can be sourced, and in which parts (see Quotes).
  • NOT_ENOUGH_STOCK instead of "temporarily unavailable". An order for more than can be sourced used to answer PROVIDER_ERROR (503), which says "retry later" about a product you could have sold a smaller amount of that minute. It now answers NOT_ENOUGH_STOCK (400) with availableAmount — the amount that would go through. New errorCode value: accept it before relying on strict enum validation.
  • Split orders (opt-in). With "allowSplit": true a request that one source cannot fill is placed as two ordinary orders with the same deliveryData, 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 with PRICE_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. Use region to 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 nameChecked before 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 robloxLogpassVerification webhook (see Webhooks), or read the link back from GET /api/v3/utils/roblox/logpass-order/:orderId/verification;
  • these orders stay pending until 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 secret field 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.id starts with the parent game.id (e.g. highrise:gold_pack belongs to game highrise)
  • product.id starts with the parent category.id (e.g. highrise:gold_pack:999_v2 belongs to category highrise: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

FieldDescription
iconA 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 }
		]
	}
}
FieldDescription
amountThe amount you asked about
fillableAmountHow much of it can be sourced right now. Equal to amount when all of it can; 0 when none
totalPriceWhat fillableAmount costs you, your fee included
partsHow 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

FieldDescription
iconA URL for icon file, or null when the product has no icon. Never ""
pricePrice in USD for 1 unit of product
typeType of product
platformActivation platform of the code / gift card, or null — see below
deliveryDataSpecifies which data is required from the customer to complete the order
amountTypeSpecifies the rules for the amount field that apply to this product
metadataDescriptive 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 valueDescription
steamSteam (Valve)
xboxXbox / Microsoft Store / Game Pass
psnPlayStation Network
nintendoNintendo eShop / Switch Online
gogGOG.com
epicEpic Games Store
eaEA app / EA Play / Origin
ubisoftUbisoft Connect
google_playGoogle Play
appleApp Store / iTunes
otherRedeemed somewhere else (e.g. a streaming-service gift card)

platform is null when no activation platform applies:

  • always for type: "topup" and type: "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_card product 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 valueDescription
topupDirect delivery to account
cdkeyYou will receive a CD key for product in webhook
gift_cardYou will receive a CD key for product in webhook (semantically a gift card; same delivery channel as cdkey)
subscriptionDirect delivery to account (semantically a period subscription; same delivery channel as topup)
accountYou 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

PropDescription
idUnique ID of field. Can be used as name in html form
labelLabel of field for customer in english
requiredIndicates whether this field is mandatory or not
typeIndicates 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 deliveryData back to you shows it as "[hidden]".
  • A secret field is never required on 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:

  1. If a translation exists for the resolved language and product slug, the translated description is returned.
  2. Otherwise, if a default description is configured for the product slug, the default description is returned.
  3. Otherwise, description is 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.

RequestReturns
GET /api/v3/products?categoryId=…&include=metadatathe light half on every product
GET /api/v3/catalog?include=metadatathe light half on every product
GET /api/v3/products/<id>?include=metadatathe full object
GET /api/v3/catalog/metadatathe 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"
}
ParamMeaning
updatedSinceISO-8601 timestamp or epoch milliseconds. Omit for a full walk
cursorOpaque; pass back nextCursor to continue. Do not construct one
limit1–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

  • about is pre-parsed into typed blocks of plain text rather than HTML, so it can be rendered without dangerouslySetInnerHTML. Inline emphasis from the original copy is not preserved.
  • countryPolicy describes 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. countryPolicyKind mirrors just the discriminator into the light half so a listing can badge "region locked" without carrying country arrays.
  • region.excludedCountries carries carve-outs (a "Europe except BY/RU" key). Treat countryPolicy as authoritative for eligibility; region is a display hint.
  • isReleased: false means the product is announced but not yet released — a pre-order. It is always accompanied by a future releaseDate. An absent isReleased means "unknown", not "unreleased": top-ups, in-game currency and most already-released games carry no flag at all.
  • media.images are always served from this API's own domain. media.video is an identifier, not a URL: build the embed yourself from provider + id.
  • ageRating.descriptors are PEGI content descriptors (violence, bad_language, gambling, online, …). An empty array means rated with nothing flagged; an absent ageRating means unrated or unknown.
  • languages are 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![где найти](https://api.example.com/static/icons/instructions/ab12.webp)"
}
FieldDescription
productIdThe product id from the request
levelHow specific the returned instruction is: product, category, or game
contentThe 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):

  1. a general instruction on this product (level: "product")
  2. a general instruction on this product's category (level: "category")
  3. a general instruction on the game (level: "game")
  4. for activation-code products (type cdkey or gift_card), the default instruction shared by all of them — reported as level: "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:

  1. The header is parsed by RFC 9110 quality values; equal weights keep request order.
  2. 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).
  3. 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:

FieldDescription
allowSplittrue lets the request be placed as two orders when one source cannot fill it — see Split orders. Default false
maxTotalPriceReject 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:

KeyMeaning
loginWhat the buyer signs in with: an e-mail, a username or a phone number
passwordThe account password
recoveryEmailA mailbox tied to the account, for recovery or for sign-in codes
noteAnything else the supplier sent, as free text: a 2FA secret, a profile name and PIN, a link
expiryHow 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 externalIdSame payloadDifferent payload
NoneNew orderNew order
pending, completed or partially_completedThat order is returned again. No new order and no second charge409 DUPLICATE_EXTERNAL_ID
Only failed onesNew orderNew 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 errorCode values. PROVIDER_CANCELLED and DELIVERY_UNCONFIRMED are new. Both can appear in the POST /api/v3/orders response and in the statusChange webhook. 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_ERROR mean two different things — "the supplier flapped, retry" and "we cannot tell whether the goods landed". Branch as follows: treat PROVIDER_CANCELLED like PROVIDER_ERROR (retryable, not the customer's fault), and treat DELIVERY_UNCONFIRMED as 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_ERROR stays 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 - Conflict
    • DUPLICATE_EXTERNAL_ID — the externalId was already used for a different order payload
    • PRICE_CHANGED — the live total is above the maxTotalPrice you 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:

CodeMeaningWhat to do
INVALID_CREDENTIALSThe player identifier (UID, server, username) was rejected by the game or the providerAsk the customer to correct the data and re-order
INVALID_AMOUNTThe requested quantity or denomination was rejectedRe-read the product's amountType and re-order
PRODUCT_NOT_FOUNDThe product is unknown, disabled or out of stock upstreamRefresh the catalog; do not retry the same product
PRODUCT_ALREADY_BOUGHTThe player already owns this one-time purchase (webhook only)Nothing to retry
USER_REACH_LIMITThe player hit a purchase limit imposed by the gameRetry later
REGION_RESTRICTEDThe product cannot be delivered to this customer's game region or account countryDo not retry with the same account; check the product's regional availability
NOT_ENOUGH_SHOP_BALANCEYour shop balance cannot cover the orderTop up and re-order
NOT_ENOUGH_STOCKThe product is on sale, but the requested amount is more than can be sourced right now. Nothing was created or chargedOrder 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_CHANGEDThe live total came out above your maxTotalPrice (HTTP only). Nothing was created or charged; totalPrice is what it would have costRe-read the price, confirm with the customer, re-order
PROVIDER_ERRORThe upstream provider is unavailable, misconfigured or out of stock, and no amount at all can be sourced — never the customer's faultRetry later
PROVIDER_CANCELLEDThe order was cancelled upstream before delivery and was not charged. Distinct from PROVIDER_ERROR: the order was accepted and then taken back, not refusedRetry later
DELIVERY_UNCONFIRMEDNobody can say whether the goods were delivered. The order was not charged, but the target account may already have received themDo not re-order the same account before support has checked; report the errorId
ROBLOX_NOT_IN_GAME, ROBLOX_NEVER_PLAY, ROBLOX_GAME_ERRORRoblox-specific delivery failures (webhook only)See the Roblox section
ROBLOX_SUPERPASS_UNAVAILABLENo SuperPass worker is available to process the orderRetry later
PARTIAL_FULFILLMENTA bundle delivered fewer units than ordered (webhook only, with partially_completed)Only the delivered units are charged
DUPLICATE_EXTERNAL_IDThe 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_ERRORA fault on our sideRetry later; report with the errorId
UNKNOWN_ERRORThe order failed and the cause could not be attributedReport 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 credentials
  • USER_REACH_LIMIT - When player has reached purchase limit
  • REGION_RESTRICTED - When the provider rejected the delivery because of the customer's game region or account country
  • NOT_ENOUGH_SHOP_BALANCE - When shop balance is insufficient
  • ROBLOX_SUPERPASS_UNAVAILABLE - When SuperPass service has no available workers to process the order
  • PRODUCT_NOT_FOUND, INVALID_AMOUNT, PROVIDER_ERROR, PROVIDER_CANCELLED - When the upstream provider rejected the order and named the reason
  • DELIVERY_UNCONFIRMED - When the outcome of the delivery could not be established either way
  • UNKNOWN_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 paramDescription
offsetOptional offset for list
limitOptional 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/v3 yet. They are sold through /api/v1 only. 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):

FieldDescription
requestedQuantityUnits the bundle was meant to deliver (N)
deliveredQuantityUnits 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_completed is a new value of status, and PARTIAL_FULFILLMENT is a new errorCode. They can appear in the POST /api/v3/orders response, GET /api/v3/orders, GET /api/v3/orders/:id, and the statusChange webhook. Clients that strictly validate the status/error enums must accept these values first. Treat partially_completed as terminal success for the delivered units, and use deliveredQuantity / cdKeys for 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:

FieldDescription
split.groupIdSame on every order of the request — the id of the first one
split.index / split.countThis order's position, and how many orders the request became (at most 2)
split.requestedAmountThe amount you sent; the orders' amounts add up to it
linkedOrdersThe 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.
  • externalId is shared by both orders, and replaying the request (same externalId, same payload, with or without allowSplit) returns the same group with 200 — 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 201 with the orders, and an order that failed carries its own errorCode (and errorId). 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 pending in 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:

FieldDescription
categoryNameName of the product's category
gameNameName of the product's game
gameIconURL 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/v1 only for now. roblox:passes:<id> products are not on v3: GET /api/v3/products/roblox:passes:<id> answers 404 and POST /api/v3/orders answers PRODUCT_NOT_FOUND. Use the /api/v1 paths 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"
	}
]
FieldDescription
rate0-100 rating
in_gamewhether client is required to join the game during the trade for this game rules
superpass_enabledwhether the game is available for superpass method
superpass_orderscurrent 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:passes it will return empty array.

But GET /api/v1/products/roblox:passes:692041880fdd2147559b5cb6 still 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 robloxSuperpassInGame event 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 a robloxLogpassVerification event carrying a verification.url. The buyer must open that page and sign into their Roblox account (including 2FA) there. Until they do, the order stays pending — 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 from GET /api/v3/utils/roblox/logpass-order/:orderId/verification.

The password and recovery_code delivery-data fields are type: "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:

StatusBodyMeaning
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
}
StatusBodyMeaning
200{ "username": "someuser" }The player exists
200{ "username": null }No such player
400Invalid idThe 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"
}
FieldDescription
usernameThe player's in-game nickname, or null when the game did not answer it
nameCheckedfalse means the nickname is unknown — the id is still valid, but do not show a name
regionThe 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:

regionCategory 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:

StatusBodyMeaning
400Invalid 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. GabeN and gaben are 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:

StatusBodyMeaning
400Invalid 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 POST method
  • Has Content-Type: application/json
  • Includes a cryptographic signature in the Sign header
  • 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:

  1. Read the raw request body as a string (do not JSON.parse before hashing).
  2. Compute HMAC_SHA256(body, shopToken) and hex-encode the result.
  3. Compare it with the value of the Sign header.
  4. 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 200 quickly (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.status will be "pending" — the order is not yet complete
  • Display the employee's username and profileUrl to the buyer
  • The final result (completed / failed) will arrive later via a regular statusChange webhook

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.status will 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 pending indefinitely, so treat a missed delivery of this webhook as a stuck order and read the link back from GET /api/v3/utils/roblox/logpass-order/:orderId/verification
  • Any credential fields in order.deliveryData read "[hidden]" — we never echo them back
  • The final result (completed / failed) arrives later via a regular statusChange webhook

Security Best Practices

  • Enforce HTTPS and reject plain HTTP.
  • Use a distinct, unguessable webhook path (e.g. /webhooks/nexus/<uuid>).
  • Validate Content-Type is application/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 idsFixed for the product; the same field has the same id across a gameMay change between requests
Name, icon, typeChange only when the product doesMay change between requests
Duplicate productsListed onceThe same product may be listed under several ids
Hot productsGET /api/v3/hot-productsGET /api/v2/shops/my/hot-products
Roblox SuperPassNot available yetAvailable
BundlesNot listed yetListed
BalanceGET /api/v2/shops/my/balanceGET /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:

  1. 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.
  2. Build checkout forms from the v3 deliveryData of each product. Field ids can differ from the ones v1 shows for the same product (for example, player_id instead of userId). If your code or database holds field ids from v1, update them: an order with v1 field ids is rejected on v3.
  3. Create orders with POST /api/v3/orders, using v3 product ids and v3 forms.
  4. Read the featured list from GET /api/v3/hot-products, if you use it.
  5. 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

  1. 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.
  2. 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.
  3. Check GET /api/v3/sandbox. It requires no token and returns {"mode":"sandbox","realMoney":false,"providers":["test"]}. Shop API responses include X-Nexus-Environment: sandbox.
  4. Read /api/v3/games, categories and products using the usual API described below. Render each product's deliveryData form and honor its amountType. 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:

ServiceCategoriesBehavior
test-1test-1:det-topup, test-1:det-codes, test-1:det-accountsResult chosen with deliveryData.outcome
test-2test-2:rnd-topup, test-2:rnd-codes, test-2:rnd-accountsRandom 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):

OutcomeInitial responseFinal result
success201, status: completedVirtual purchase succeeds
fail503, errorCode: PROVIDER_ERRORFailed; no funds consumed
fail_credentials400, errorCode: INVALID_CREDENTIALSFailed; no funds consumed
pending_success201, status: pendingCompleted after about 15 seconds
pending_fail201, status: pendingFailed 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:

EndpointSandbox fixtures
/utils/pubg/player/:idPositive ID → Sandbox-<id>; 1 → null username; 2 → HTTP 503
/utils/fireloot/player/:uidNumeric UID → simulated CIS player; 0 → 404 not_found; 1 → 404 region_unsupported; 2 → 502
/utils/genshin/player/:server/:playerValid server and positive player → simulated username; player 1 → null
/utils/steam/account/:loginAny login → {"exists": true}; nosuchlogin → 404 not_found; lookupfailed → 502
/utils/roblox/get-user/:usernameFixed user ID 10000001 and supplied username
/utils/roblox/get-places/:idOne simulated place
/utils/roblox/get-passes/:idOne 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.

Download nexus-shop-api.md