---
version: 1.5.0
---

# Nexus Shop API

This document describes how your shop communicates securely with the
Nexus platform.

## 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/v1`);
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/v1/sandbox`. It requires no token and returns
   `{"mode":"sandbox","realMoney":false,"providers":["test"]}`. Shop API
   responses include `X-Nexus-Environment: sandbox`.
4. Read `/api/v1/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/v1/sandbox/balance` with shop authorization:

```json
{ "sandbox": true, "balance": 1000 }
```

Use **+1,000 test USD** in My shops or `POST /api/v1/sandbox/topup` with the
same token (no request body required). A successful top-up returns HTTP 201:

```json
{ "balance": 2000, "credited": 1000, "sandbox": true }
```

Self-service top-ups add 1,000 USD at a time, at most once per minute and only
if the resulting available balance is at most 10,000 USD. A top-up within a
minute of shop creation or the previous deposit returns 429; exceeding the
balance cap returns 409. Both return `{"error":"..."}`. No payment is needed.
These endpoints exist only in sandbox; they do not change your real balance.

Purchases apply the normal shop commission. Completed and pending orders
consume available funds; failed orders release their reservation. Always use
the API's actual product prices and order `totalPrice`. Top-ups preserve order
history and `externalId` idempotency; they do not reset the shop.

### Predictable purchase scenarios

The sandbox exposes two synthetic services:

| Service | Categories | Behavior |
| --- | --- | --- |
| `test-1` | `test-1:det-topup`, `test-1:det-codes` | Result chosen with `deliveryData.outcome` |
| `test-2` | `test-2:rnd-topup`, `test-2:rnd-codes` | Random success, failure or delayed result |

Each category contains `p1` through `p5`; for example,
`test-1:det-topup:p1` and `test-1:det-codes:p1`. There are 20 products in total.
`p1` and `p2` accept only amount 1. The other products exercise multiple-amount
rules: `p3` accepts 1–100; `p4` accepts 10–1000 in steps of 10; `p5` accepts
1–10 in steps of 1. Check the returned `amountType` when constructing requests.

Set `account` to any fictitious value. For `test-1`, select one of these
`outcome` values (HTTP statuses refer to a **new**, valid order):

| Outcome | Initial response | Final result |
| --- | --- | --- |
| `success` | 201, `status: completed` | Virtual purchase succeeds |
| `fail` | 503, `errorCode: PROVIDER_ERROR` | Failed; no funds consumed |
| `fail_credentials` | 400, `errorCode: INVALID_CREDENTIALS` | Failed; no funds consumed |
| `pending_success` | 201, `status: pending` | Completed after about 15 seconds |
| `pending_fail` | 201, `status: pending` | Failed after about 15 seconds; funds released |

The operator can configure the pending delay. Restarting the sandbox API does
not discard pending orders: they are recovered from the database. Cdkey products
return synthetic `TEST-...` codes when completed. They have no redemption value.

```bash
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/v1/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. Use a **new externalId for each different
scenario**; reusing one with different order data returns 409. Poll
`GET /api/v1/orders/<id>` or
`GET /api/v1/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](#webhooks): HMAC-SHA256 of the raw
body using the complete **sandbox** `id:secret` token. Keep your test receiver
separate from production processing, return a successful response promptly,
and handle duplicates. Localhost/private receiver URLs are rejected by Nexus;
use a public HTTPS endpoint or your own tunnel for local development.

Lookup endpoints return fixtures, without contacting the real game/provider:

| Endpoint | Sandbox fixtures |
| --- | --- |
| `/utils/pubg/player/:id` | Positive ID → `Sandbox-<id>`; `1` → null username; `2` → HTTP 502 |
| `/utils/fireloot/player/:uid` | Numeric UID → simulated CIS player; `0` → 404 `not_found`; `1` → 404 `region_unsupported`; `2` → 502 |
| `/utils/genshin/player/:server/:player` | Valid server and positive player → simulated username; player `1` → null |
| `/utils/roblox/get-user/:username` | Fixed user ID `10000001` and supplied username |
| `/utils/roblox/get-places/:id` | One simulated place |
| `/utils/roblox/get-passes/:id` | One purchasable and one unavailable simulated pass |

All paths in this table start with `/api/v1` 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.

## What's new in 1.5.0

**Free Fire orders can be checked before the buyer pays.**
`GET /api/v1/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/v1/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 will respond with an
authentication error.

## API Endpoints

### Data format

```typescript
type Game = {
	id: string;
	name: string;
	icon: string;
	tags: string[]; // tag keys; see GET /api/v1/tags for localized labels
};

type Category = {
	id: string;
	name: string;
};

type Product = {
	id: string;
	name: string;
	icon: string;
	price: number;
	deliveryData: FormField[];
	type: "topup" | "cdkey" | "gift_card" | "subscription";
	// Activation platform of the code / gift card; null when not applicable.
	platform: Platform | null;
	// Whether GET /api/v1/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 = announced but not yet released (pre-order)
	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/v1/catalog` retrieve all games, categories and products in a single request.

```http
GET /api/v1/catalog
Authorization: Shop 111:aaabbbccc
```

```json
{
	"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/v1/products`. The response is cached server-side for up to 5 minutes.

### Games

`GET /api/v1/games/` retrieve a list of all available games in `Game[]` format.

```http
GET /api/v1/games/
Authorization: Shop 111:aaabbbccc
```

```json
[
	{
		"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/v1/games/<id>` retrieve a specific game in `Game` format.

```http
GET /api/v1/games/highrise
Authorization: Shop 111:aaabbbccc
```

```json
{
	"id": "highrise",
	"name": "Highrise",
	"icon": "https://cdn.example.com/icons/highrise.png"
}
```

#### Explanation for type Game

| Field  | Description         |
| ------ | ------------------- |
| `icon` | A URL for icon file |

### Tags

`GET /api/v1/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.

```http
GET /api/v1/tags
Authorization: Shop 111:aaabbbccc
Accept-Language: ru
```

```json
[
	{ "key": "games", "name": "Игры" },
	{ "key": "gift-cards", "name": "Подарочные карты" }
]
```

`GET /api/v1/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.

```http
GET /api/v1/games?tag=games
Authorization: Shop 111:aaabbbccc
```

```json
[
	{ "id": "highrise", "name": "Highrise", "icon": "https://cdn.example.com/icons/highrise.png", "tags": ["games"] }
]
```

### Categories

`GET /api/v1/categories?gameId=<id>` retrieve a list of all available categories
in `Category[]` format for a specific game. `gameId` search param is
required.

```http
GET /api/v1/categories?gameId=highrise
Authorization: Shop 111:aaabbbccc
```

```json
[
	{ "id": "gold_pack", "name": "Gold Packs" },
	{ "id": "season_pass", "name": "Season Pass" }
]
```

`GET /api/v1/categories/<id>` retrieve a specific category in `Category` format.

```http
GET /api/v1/categories/gold_pack
Authorization: Shop 111:aaabbbccc
```

```json
{
	"id": "gold_pack",
	"name": "Gold Packs"
}
```

### Products

`GET /api/v1/products?categoryId=<id>` retrieve a list of all available products
in `Product[]` format for a specific category. `categoryId` search param is
required.

```http
GET /api/v1/products?categoryId=gold_pack
Authorization: Shop 111:aaabbbccc
```

```json
[
	{
		"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/v1/products/<id>` retrieve a specific product in `Product` format.

```http
GET /api/v1/products/highrise:gold_pack:999_v2
Authorization: Shop 111:aaabbbccc
```

```json
{
	"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 type

| Field          | Description                                                              |
| -------------- | ------------------------------------------------------------------------ |
| `icon`         | A URL for icon file                                                      |
| `price`        | Price in USD for 1 unit of product                                       |
| `type`         | Type of product                                                          |
| `platform`     | Activation platform of the code / gift card, or `null` — see below       |
| `deliveryData` | Specifies which data is required from the customer to complete the order |
| `amountType`   | Specifies the rules for the `amount` field that apply to this product    |
| `metadata`     | Descriptive product metadata; opt-in via `?include=metadata` — see below |

#### Product.platform

The platform the customer redeems the product on. Intended for storefront
platform filters and Steam/Xbox/PSN badges, and therefore most relevant for
`type: "cdkey"` and `type: "gift_card"`.

The value is one of a **fixed** set of machine slugs:

| Field value   | Description                                                       |
| ------------- | ----------------------------------------------------------------- |
| `steam`       | Steam (Valve)                                                     |
| `xbox`        | Xbox / Microsoft Store / Game Pass                                |
| `psn`         | PlayStation Network                                               |
| `nintendo`    | Nintendo eShop / Switch Online                                    |
| `gog`         | GOG.com                                                           |
| `epic`        | Epic Games Store                                                  |
| `ea`          | EA app / EA Play / Origin                                         |
| `ubisoft`     | Ubisoft Connect                                                   |
| `google_play` | Google Play                                                       |
| `apple`       | App Store / iTunes                                                |
| `other`       | Redeemed somewhere else (e.g. a streaming-service gift card)      |

`platform` is `null` when no activation platform applies:

- **always** for `type: "topup"` and `type: "subscription"` — these are activated
  by us directly on the buyer's account, so there is no store to send anyone to;
- 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 value | Description                                      |
| ----------- | ------------------------------------------------ |
| `topup`        | Direct delivery to account                                                                                       |
| `cdkey`        | You will receive a CD key for product in webhook                                                                 |
| `gift_card`    | You will receive a CD key for product in webhook (semantically a gift card; same delivery channel as `cdkey`)    |
| `subscription` | Direct delivery to account (semantically a period subscription; same delivery channel as `topup`)                |

#### Product.deliveryData

`deliveryData` field in the `Product` type is an array of fields that must be
filled in by the customer to purchase this product.

The following properties are common for all field types

| Prop     | Description                                            |
| -------- | ------------------------------------------------------ |
| id       | Unique ID of field. Can be used as `name` in html form |
| label    | Label of field for customer in english                 |
| required | Indicates whether this field is mandatory or not       |
| type     | Indicates the field type                               |

##### Text type

A simple text field. _Can_ have a `regex` field that applies rules to the input.

##### Number type

Number field. _Can_ have `min`, `max` and `step` configuration identical to HTML
attributes for an input of type `number` (`<input type="number"/>`).

##### Select type

A select field that provides a list of options from which the user must choose
one. Options are an array of objects with `id` and `label`. The `label` is the
English name of the option displayed to the customer. The `id` is the actual
value that must be sent during order creation.

##### Secret type

A credential belonging to the customer — today a Roblox account password or a
Microsoft recovery code. On the wire it behaves like a text field and _can_ have
a `regex`, but it carries obligations a text field does not:

- **Render it masked** (`<input type="password"/>`) and never pre-fill it.
- **Do not store it.** It exists only to be forwarded to the supplier for this
  one order. We do not keep it either: it is erased from our order record as
  soon as the supplier accepts the order, and every response and webhook that
  echoes `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

```json
{
	...
	"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

```json
{
	...
	"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

```json
{
	"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

```json
{
	"type": "multiple",
	"min": 50,
	"step": 1
}
```

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

| Request | Returns |
| --- | --- |
| `GET /api/v1/products?categoryId=…&include=metadata` | the **light** half on every product |
| `GET /api/v1/catalog?include=metadata` | the **light** half on every product |
| `GET /api/v1/products/<id>?include=metadata` | the **full** object |
| `GET /api/v1/catalog/metadata` | the **full** object for many products — the recommended integration |

The split exists for size. The light half is ~150–250 bytes per product; the
full object can carry ~3 KB, and the whole catalog is tens of thousands of
products, so full metadata is never inlined into a listing. List endpoints
return the light half **regardless** of what `include` asks for — use
`/products/<id>` or `/catalog/metadata` to get the rest.

`metadata` is `null` when we hold nothing for that product. An **absent** key
means metadata was not requested (or the deployment predates the feature) — the
same convention as `platform`.

#### Bulk sync

`GET /api/v1/catalog/metadata` walks the whole metadata set, newest changes
last, and is designed to be mirrored into your own storage.

```http
GET /api/v1/catalog/metadata?updatedSince=2026-07-01T00:00:00Z&limit=200
Authorization: Shop 111:aaabbbccc
```

```json
{
	"items": [
		{
			"productId": "gta-v:keys:standard",
			"version": "9f2c…",
			"updatedAt": "2026-07-26T19:41:03.117Z",
			"metadata": { "developer": "Rockstar North", "…": "…" }
		}
	],
	"nextCursor": "MTc4NTEwMjkxMzA4NXxndGEtdjprZXlzOnN0YW5kYXJk",
	"syncedAt": "2026-07-27T08:12:44.001Z"
}
```

| Param | Meaning |
| --- | --- |
| `updatedSince` | ISO-8601 timestamp or epoch milliseconds. Omit for a full walk |
| `cursor` | Opaque; pass back `nextCursor` to continue. Do not construct one |
| `limit` | 1–1000, default 200 |

Loop until `nextCursor` is `null`, then store `syncedAt` and pass it as the next
run's `updatedSince`. `syncedAt` is captured **before** the page is read, so a
product changed mid-walk lands in your next sync rather than being skipped.

`version` is an opaque content hash: equal `version` means the metadata is
byte-identical, so you can skip re-processing without diffing. It changes only
when the content actually changes, which is also why a re-sync of an unchanged
catalog returns nothing.

Only products visible to your shop are returned. Pages may therefore come back
shorter than `limit` (or empty) while the cursor still advances — that is normal,
keep following `nextCursor` and stop only when it is `null`.

Responds `503` while the feature is disabled for the deployment.

#### Notes on individual fields

- **`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. `releaseDate` may be present in that case.
- **`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/v1/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/v1/products/<id>`).

**Check `haveInstruction` first.** Every product in `GET /api/v1/catalog` and
`GET /api/v1/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).

```http
GET /api/v1/instructions/highrise:gold_pack:999_v2
Authorization: Shop 111:aaabbbccc
Accept-Language: ru
```

```json
{
	"productId": "highrise:gold_pack:999_v2",
	"level": "product",
	"content": "## Как заполнить форму\n\nВведите **Username** из профиля игры…\n\n![где найти](https://api.example.com/static/icons/instructions/ab12.webp)"
}
```

| Field       | Description                                                                       |
| ----------- | --------------------------------------------------------------------------------- |
| `productId` | The product id from the request                                                   |
| `level`     | How specific the returned instruction is: `product`, `category`, or `game`         |
| `content`   | The instruction body in Markdown (may contain images as absolute URLs)            |

The server returns the **single most specific** instruction available, checked
in this order (a more specific level overrides a more general one):

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"`)

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](#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/v1/catalog
  GET /api/v1/games
  GET /api/v1/games/:id
  GET /api/v1/categories
  GET /api/v1/categories/:id
  GET /api/v1/products
  GET /api/v1/products/:id
  GET /api/v1/instructions/:id
  GET /api/v1/tags
  GET /api/v1/catalog/metadata
  GET /api/v2/shops/my/hot-products

### Orders

`POST /api/v1/orders` to create a new order.

```http
POST /api/v1/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
}
```

```json
{
	"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"
}
```

#### Order creation fail

A failed order create returns status code 400, 409, 500 or 503 with the
following response body.

```typescript
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_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
};
```

**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/v1/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`, `USER_REACH_LIMIT`, `REGION_RESTRICTED`
- `409` - Conflict
  - `DUPLICATE_EXTERNAL_ID` — the `externalId` was already used for a different order payload
- `500` - Server errors (internal failures)
  - `SERVER_ERROR`, `UNKNOWN_ERROR`, `DELIVERY_UNCONFIRMED`
- `503` - Service temporarily unavailable (retry later)
  - `PROVIDER_ERROR`, `PROVIDER_CANCELLED`, `ROBLOX_SUPERPASS_UNAVAILABLE`

**What each code means:**

| Code | Meaning | What to do |
| --- | --- | --- |
| `INVALID_CREDENTIALS` | The player identifier (UID, server, username) was rejected by the game or the provider | Ask the customer to correct the data and re-order |
| `INVALID_AMOUNT` | The requested quantity or denomination was rejected | Re-read the product's `amountType` and re-order |
| `PRODUCT_NOT_FOUND` | The product is unknown, disabled or out of stock upstream | Refresh the catalog; do not retry the same product |
| `PRODUCT_ALREADY_BOUGHT` | The player already owns this one-time purchase (webhook only) | Nothing to retry |
| `USER_REACH_LIMIT` | The player hit a purchase limit imposed by the game | Retry later |
| `REGION_RESTRICTED` | The product cannot be delivered to this customer's game region or account country | Do not retry with the same account; check the product's regional availability |
| `NOT_ENOUGH_SHOP_BALANCE` | Your shop balance cannot cover the order | Top up and re-order |
| `PROVIDER_ERROR` | The upstream provider is unavailable, misconfigured or out of funds — never the customer's fault | Retry later |
| `PROVIDER_CANCELLED` | The order was cancelled upstream before delivery and was not charged. Distinct from `PROVIDER_ERROR`: the order was accepted and then taken back, not refused | Retry later |
| `DELIVERY_UNCONFIRMED` | Nobody can say whether the goods were delivered. The order was not charged, but the target account may already have received them | **Do not re-order the same account** before support has checked; report the `errorId` |
| `ROBLOX_NOT_IN_GAME`, `ROBLOX_NEVER_PLAY`, `ROBLOX_GAME_ERROR` | Roblox-specific delivery failures (webhook only) | See the Roblox section |
| `ROBLOX_SUPERPASS_UNAVAILABLE` | No SuperPass worker is available to process the order | Retry later |
| `PARTIAL_FULFILLMENT` | A bundle delivered fewer units than ordered (webhook only, with `partially_completed`) | Only the delivered units are charged |
| `DUPLICATE_EXTERNAL_ID` | The `externalId` is already bound to a different order payload (HTTP only) | Use a fresh `externalId`, or re-send the identical payload to replay idempotently |
| `SERVER_ERROR` | A fault on our side | Retry later; report with the `errorId` |
| `UNKNOWN_ERROR` | The order failed and the cause could not be attributed | Report with the `errorId` |

**`errorCode` on the order object.**
Until now the cause of a failure travelled only on the `statusChange` webhook,
which meant a shop that missed one — a restart, a deploy, a brief outage — had
no second source: `GET /orders/:id` answered `failed` and nothing else. The
order object now carries `errorCode` too, so the cause is recoverable by
polling.

It is present only when we actually know the cause; a failure the supplier did
not explain still arrives without it, and the field is absent rather than
`UNKNOWN_ERROR`, so "we do not know" stays distinguishable from "the supplier
said UNKNOWN_ERROR". The webhook's own top-level `errorCode` is unchanged —
prefer it when you have it, and treat the order field as the fallback.

**Error ID (`errorId`):**
The `errorId` field is included for order execution errors (errors that occur during the order fulfillment process). It's a UUID v7 that can be used to correlate errors with server logs when contacting support. The `errorId` is present for:
- `INVALID_CREDENTIALS` - When order execution fails due to invalid player 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:**
```json
{
	"errorCode": "USER_REACH_LIMIT",
	"errorMessage": "User reached a limit of purchase in game",
	"errorId": "019473a2-7c3f-7000-8000-000000000001"
}
```

`GET /api/v1/orders` to get all your orders.

| Query param | Description              |
| ----------- | ------------------------ |
| offset      | Optional offset for list |
| limit       | Optional limit for list  |

```http
GET /api/v1/orders?offset=20&limit=10
Authorization: Shop 111:aaabbbccc
```

```json
[{
	"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/v1/orders/:id` to get certain order.

```http
GET /api/v1/orders/42
Authorization: Shop 111:aaabbbccc
```

```json
{
	"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

A **bundle** is an ordinary catalog product whose purchase places several units
of one underlying component product (e.g. a "32000 UC" bundle that buys 4 × 8000
UC). You order it exactly like any other product — `amount` **must** be `1`, and
its catalog price is the sum of those units.

Because each unit is fulfilled independently, a bundle order can be **partially
fulfilled**. Two extra fields appear on bundle orders (and are `null` on every
non-bundle order):

| Field               | Description                                  |
| ------------------- | -------------------------------------------- |
| `requestedQuantity` | Units the bundle was meant to deliver (N)    |
| `deliveredQuantity` | Units actually delivered (M ≤ N)             |

A new order status — **`partially_completed`** — is reported when `0 < M < N`.
In that case `totalPrice` is reduced to the price of the delivered units only:
you are **never charged for undelivered units** — the difference is restored to
your balance automatically, exactly as for a `failed` order. For code-based
products, `cdKeys` then contains only the delivered keys.

> **Contract change — action required before selling bundles.**
> `partially_completed` is a new value of `status`, and `PARTIAL_FULFILLMENT` is
> a new `errorCode`. They can appear in the `POST /api/v1/orders` response,
> `GET /api/v1/orders`, `GET /api/v1/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.

### Balance

`GET /api/v2/shops/my/balance` retrieve your current shop balance in USD.

```http
GET /api/v2/shops/my/balance
Authorization: Shop 111:aaabbbccc
```

```json
{
	"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/v2/shops/my/hot-products` retrieve the curated list of hot (featured) products, ordered by the admin-defined priority.

```http
GET /api/v2/shops/my/hot-products
Authorization: Shop 111:aaabbbccc
```

```json
[
	{
		"id": "highrise:gold_pack:999_v2",
		"name": "Gold Pack — 999",
		"icon": "https://cdn.example.com/products/gold_999.png",
		"price": 9.99,
		"deliveryData": [
			{
				"type": "text",
				"id": "username",
				"label": "Username",
				"required": true,
				"regex": "\\S+"
			}
		],
		"type": "topup",
		"amountType": { "type": "one" },
		"categoryName": "Gold Packs",
		"gameName": "Highrise",
		"gameIcon": "https://cdn.example.com/icons/highrise.png"
	}
]
```

Each product in the response is identical to the `Product` type described above, with three additional fields:

| Field          | Description                        |
| -------------- | ---------------------------------- |
| `categoryName` | Name of the product's category     |
| `gameName`     | Name of the product's game         |
| `gameIcon`     | URL for the game's icon            |

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/v1/utils/roblox/get-user/<username>`

```json
{
	"id": "5642960000",
	"username": "someuser"
}
```

##### Get user's places

`GET /api/v1/utils/roblox/get-places/<user_id>`

```json
[
	{
		"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/v1/utils/roblox/get-passes/<place_id>`

```json
{
	"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

`GET /api/v1/utils/roblox/super-games`

```json
[
	{
		"id": "69203a7f0fdd2147559b5b97",
		"name": "[🔥UPD] Гиперстрельба",
		"name_en": "[🔥UPD] Hypershot",
		"uri": "https://www.roblox.com/games/17516596118",
		"image_uri": "https://storage.yandexcloud.net/roblox/1763719805017818977_6376.jpg",
		"description": "✨Добро пожаловать в Hypershot!✨\nПрыгайте в нашу интенсивную и быструю стрельбу от первого лица, где мастерство, стиль и хаос преобладают!\n\n🏆Доминируйте в интенсивных классических режимах игры, таких как командный матч смерти, захват флага, бесплатная игра для всех и игра с оружием (Арсенал).\n💥 Объединитесь с друзьями, чтобы бросить вызов соперникам в дуэлях 1 на 1 или 2 на 2\n👑Поднимитесь по рангам, чтобы достичь таблицы лидеров!\n🌀Овладейте своими способностями, чтобы перехитрить и перехитрить своих врагов!\n🔥 Зарабатывайте монеты и драгоценные камни, чтобы разблокировать новое оружие, скины, способности и многое другое!\n🎮Доступно на ПК, телефонах, планшетах и консолях\n\n❤️Сделано с любовью Frosted Studios\n\n⚠️МОШЕННИЧЕСТВО В ЛЮБОЙ ФОРМЕ ПРИВЕДЕТ К ЗАПРЕТУ НА ВСЕ УЧЕТНЫЕ ЗАПИСИ⚠️",
		"rate": 95,
		"online": 25792,
		"in_game": false,
		"market_enabled": false,
		"superpass_enabled": true,
		"tags": null,
		"market_orders": 0,
		"superpass_orders": 0,
		"universe_id": "5995470825"
	},
	{
		"id": "692041ef0fdd2147559b5cc9",
		"name": "[👮] Диспетч: Полицейский симулятор",
		"name_en": "[👮] Dispatch: Police Simulator",
		"uri": "https://www.roblox.com/games/98100179608264",
		"image_uri": "https://storage.yandexcloud.net/roblox/1763721708509406099_19076.jpg",
		"description": "🚨 Полицейский симулятор:\nСтаньте настоящим офицером в высокооплачиваемом полицейском симуляторе на базе ИИ. Отвечайте на динамичные чрезвычайные ситуации, преследуйте бегущих подозреваемых, рассеивайте напряженные ситуации и восстанавливайте порядок - все это в живом, реактивном городе, которому нужна ваша помощь.\n\n👮 Как играть:\n- Присоединяйтесь к игре и выберите свое снаряжение и оружие из шкафчика.\nДождитесь появления активных вызовов в меню и ответьте на них.\n- Подойдите к месту происшествия и попробуйте справиться с подозреваемыми умным способом.\n\n⭐ Ожидайте новых обновлений каждую неделю!\n\n🎉 Присоединяйтесь к нашей группе и добавьте \"Нравится\" к игре, чтобы разблокировать $350 + 100XP.\n",
		"rate": 92,
		"online": 1027,
		"in_game": true,
		"market_enabled": false,
		"superpass_enabled": true,
		"tags": null,
		"market_orders": 0,
		"superpass_orders": 0,
		"universe_id": "8104504677"
	}
]
```

| Field             | Description                                                                      |
| ----------------- | -------------------------------------------------------------------------------- |
| rate              | 0-100 rating                                                                     |
| in_game           | whether client is required to join the game during the trade for this game rules |
| superpass_enabled | whether the game is available for superpass method                               |
| superpass_orders  | current orders count for superpass method in this game                           |

##### Get SuperPasses for certain game

`GET /api/v1/utils/roblox/super-games/<game-id>/passes`

```json
[
	{
		"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/v1/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.

```json
{
	"available": true
}
```

When `available` is `false`, any attempt to create a superpass order will fail with `503 ROBLOX_SUPERPASS_UNAVAILABLE`. Wait and retry later.

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

```json
{
	"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/v1/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.

```json
{
	"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/v1/utils/pubg/player/<id>`

If user exists you will receive next response

```json
{
	"username": "someuser"
}
```

Otherwise you will receive

```json
{
	"playerName": null
}
```

#### 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 is rejected at delivery, so resolve the market first and show the
buyer only the nominals they can actually receive.

`GET /api/v1/utils/fireloot/player/<uid>`, where `<uid>` is the numeric Free Fire player id (1 to 20 digits)

If the player exists you will receive

```json
{
	"username": "someuser",
	"nameChecked": true,
	"region": "CIS"
}
```

| Field         | Description                                                                              |
| ------------- | ---------------------------------------------------------------------------------------- |
| `username`    | The player's in-game nickname, or `null` when the game did not answer it                  |
| `nameChecked` | `false` means the nickname is unknown — the id is still valid, but do not show a name     |
| `region`      | The market the account belongs to; see the table below                                    |

`region` is the market code as the supplier spells it. The catalog spells the
same market as a slug suffix on the category:

| `region`     | Category suffix |
| ------------ | --------------- |
| `CIS`        | `-cis`          |
| `BANGLADESH` | `-bangladesh`   |
| `BRAZIL`     | `-brazil`       |
| `EU`         | `-europe`       |
| `INDONESIA`  | `-indonesia`    |
| `LATAM`      | `-latam`        |
| `MENA`       | `-mena`         |
| `PAKISTAN`   | `-pakistan`     |
| `SG`         | `-singapore`    |
| `TAIWAN`     | `-taiwan`       |
| `VIETNAM`    | `-vietnam`      |

New markets can appear without notice, so never hard-code this list: an
unknown `region` 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`

```json
{
	"error": "Player not found",
	"code": "invalid_uid"
}
```

If the account exists but plays in a market that is not for sale you will
receive `404` with a different code

```json
{
	"error": "Player's game region is not supported",
	"code": "region_unsupported"
}
```

`region_unsupported` is not a bad id — the player is real and other Free Fire
lines in the catalog may still be able to serve them.

Other responses:

| Status | Body                                     | Meaning                                                                          |
| ------ | ---------------------------------------- | -------------------------------------------------------------------------------- |
| `400`  | `Invalid id` (plain text)                | The id is not a 1 to 20 digit number                                              |
| `502`  | `{ "error": "Player lookup failed" }`    | The lookup itself failed — the verdict is unknown, retry rather than block the buyer |
| `503`  | `{ "error": "Lookup is not available" }` | Free Fire lookup is not configured on this installation                           |

A verdict is cached for 5 minutes per player id, so a nickname changed in-game
can take that long to show up.

#### Genshin

##### Check user ID for server

You can check if user with certain id exists in game using next endpoint

`GET /api/v1/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

```json
{
	"username": "someuser"
}
```

Otherwise you will receive

```json
{
	"playerName": null
}
```

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

```typescript
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

```typescript
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
	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`
	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"
	| "ROBLOX_NOT_IN_GAME"
	| "ROBLOX_NEVER_PLAY"
	| "ROBLOX_GAME_ERROR"
	| "PARTIAL_FULFILLMENT"; // accompanies a partially_completed bundle order

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)
};

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
```json
{
	"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
```json
{
	"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)
```json
{
	"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
```json
{
	"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
```json
{
	"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/v1/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.
