# Buyer API — Gaming Goods

Catalog-as-API: products, prices, stock, orders, balance, webhooks — over one API key. REST + JSON.
This document is self-contained: an LLM can integrate end-to-end from it alone, with no other docs.

- Base (prod): `https://gaming-goods.ru/api/v1`
- Base (sandbox): `https://sandbox.gaming-goods.ru/api/v1`
- Auth: HTTP header `Authorization: Bearer gg_live_…` (sandbox: `gg_test_…`)
- Human docs: https://gaming-goods.ru/buyer-api-documentation
- Machine specs: OpenAPI 3.1 `GET /api/v1/openapi.json` (buyer-only subset `GET /api/v1/openapi/pub.json`) · Postman `GET /api/v1/postman.json` · Redoc UI https://gaming-goods.ru/api-reference
- Get a key: email **ceo@vvv.cash**.

All buyer endpoints live under the `/pub/` prefix and require the key. Get a key before anything else.

---

## Conventions (read first)

**Success envelope.** Every 2xx body is wrapped in `data`:
```json
{ "data": { "...": "..." } }
```

**Error envelope.** Every 4xx/5xx (except the recipient 422, see below) is:
```json
{ "error": { "code": "UNAUTHORIZED", "message": "human-readable reason" } }
```
Branch on `error.code` (stable), not on `message` (may change).

**Money / units — this bites integrators, read it.**
- Catalog price (`/pub/products*`) is a **decimal number in EUR**, e.g. `16.05`. Field `currency` is always `"EUR"`.
- Order money (`/pub/orders*`: `total`, `unit_price`, and the webhook `total_cents`) is an **integer in euro-cents**, e.g. `1605` = €16.05.
- So the same €16.05 appears as `16.05` in the catalog and `1605` on an order. Do not mix the scales.
- Balance (`/pub/balance`) is a **decimal number in EUR**, e.g. `1234.56`.
- **Service fee — reselling math.** The order `total` (what is deducted from your balance) is the catalog `price` **plus a 10% platform service fee**; the catalog `price` does NOT include it. Your real cost per item is `price × 1.10`. Set your resale markup on top of that, not on the bare `price`, or you sell at a loss. `/pub/products` exposes no fee field — the fee is applied at order creation and shown as `total` in the `POST /pub/orders` response.

**IDs.** `product_id` / order `id` are UUIDs. Products also have a URL `slug` (e.g. `steam-top-up`); either identifies a product.

**Time.** All timestamps are RFC3339 UTC (`2026-07-01T00:00:00Z`). Query params that take time want the same.

**Environments.** `gg_test_` keys work **only** on sandbox; `gg_live_` keys work **only** on prod. Wrong pairing → `403` before any DB lookup (codes `TEST_KEY_ON_PROD` / `LIVE_KEY_ON_SANDBOX`).

**Rate limit.** Per-key, two windows (per-minute and per-day; limits are set on the key). Every response carries `X-RateLimit-Limit` (per-minute), `X-RateLimit-Remaining`, `X-RateLimit-Reset` (unix seconds). Over the limit → `429 RATE_LIMIT`. Limiter fails open if the store is unavailable.

**Auth errors** (all share this table):

| HTTP | code | when |
|---|---|---|
| 401 | `UNAUTHORIZED` | header missing, or key invalid / revoked / expired |
| 403 | `TEST_KEY_ON_PROD` | `gg_test_` key used on prod |
| 403 | `LIVE_KEY_ON_SANDBOX` | `gg_live_` key used on sandbox |
| 429 | `RATE_LIMIT` | per-minute or per-day limit exceeded |
| 500 | `INTERNAL` | auth lookup failed server-side |

---

## Quickstart (3 calls)

```bash
KEY=gg_live_your_key
BASE=https://gaming-goods.ru/api/v1

# 1) Find a product (grab its gg_product_id)
curl -s "$BASE/pub/products?q=steam&page_size=5" -H "Authorization: Bearer $KEY"

# 2) Buy it (pays from your prepaid balance)
curl -s -X POST "$BASE/pub/orders" -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{"lines":[{"product_id":"<GG_PRODUCT_ID>","quantity":1}],"payment_method":"balance","idempotency_key":"my-unique-id-001"}'

# 3) Check order status (keys are NOT here — see “Key delivery”)
curl -s "$BASE/pub/orders/<ORDER_ID>" -H "Authorization: Bearer $KEY"
```

Prerequisite for `payment_method":"balance"`: a funded prepaid balance (`GET /pub/balance`). Top-ups are arranged with the account manager.

---

## Endpoints

### GET /pub/products — list catalog

Query params (all optional):

| param | type | default | notes |
|---|---|---|---|
| `page` | int | 1 | 1-based |
| `page_size` | int | 100 | max **500** (values above are capped to 500) |
| `category` | string | — | exact category |
| `brand` | string | — | exact brand |
| `q` | string | — | free-text search |
| `min_price` | number | — | EUR |
| `max_price` | number | — | EUR |
| `updated_since` | RFC3339 | — | incremental sync: only items changed after this instant. Invalid value → `400 VALIDATION_ERROR` |
| `sort_by` | string | — | passthrough sort field |
| `sort_dir` | string | — | `asc` / `desc` |
| `include_out_of_stock` | bool | false | pass `true` to include `stock_quantity = 0` |
| `locale` | string | — | translation locale for name/description |

Response `200`:
```json
{
  "data": {
    "products": [ { "...": "PubProduct, see below" } ],
    "total": 11842,
    "page": 1,
    "page_size": 100
  }
}
```

**PubProduct object** (fields marked * may be absent):
```json
{
  "gg_product_id": "f1e2…-uuid",
  "group_id": "uuid*",
  "slug": "steam-top-up",
  "name": "Steam Top-Up 10 EUR",
  "original_name": "string*",
  "description": "string*",
  "category": "string*",
  "brand": "string*",
  "provider_type": "marketplace",
  "price": 16.05,
  "currency": "EUR",
  "stock_quantity": 342,
  "image_url": "https://*",
  "seller_id": "uuid*",
  "delivery_type": "auto",
  "moderation_status": "approved",
  "is_active": true,
  "ticker": "string*",
  "meta": { "...": "object*" },
  "specifications": [ { "key": "region", "value": "Europe" } ],
  "translations": { "...": "object*" },
  "steam_app_id": 570,
  "steam_url": "https://store.steampowered.com/app/570",
  "created_at": "2026-07-01T00:00:00Z",
  "updated_at": "2026-07-01T00:00:00Z"
}
```
Notes:
- `gg_product_id` is the stable identity (partners index on it); `group_id` links SKUs of one virtual card (may be empty).
- `delivery_type` is normalized: auto-delivered suppliers (kinguin/dpgame/bamboo) collapse to `"auto"`. Other values (`fragment`, `playwallet`, `manual_gift`) require `recipient_data` at checkout — see “Recipient data”.
- `specifications` is an **array** of `{key,value}` pairs (not an object), despite the OpenAPI type hint.
- `steam_app_id` / `steam_url` appear only for Steam-matched Kinguin SKUs.

### GET /pub/products/{slug} — one product

`{slug}` may be the URL slug. Response `200`:
```json
{ "data": { "product": { "...": "PubProduct" } } }
```
Errors: `400 BAD_REQUEST` (empty slug), `404 NOT_FOUND`, `500 INTERNAL`.

### POST /pub/orders — create an order (and pay)

Request body:
```json
{
  "lines": [ { "product_id": "uuid", "quantity": 1 } ],
  "payment_method": "balance",
  "recipient_data": { "steam_login": "dark_slayer_2077" },
  "return_url": "https://your-site/thanks",
  "idempotency_key": "my-unique-id-001"
}
```
| field | required | notes |
|---|---|---|
| `lines[]` | **yes** | `{product_id (uuid), quantity (int)}`; empty → `422 VALIDATION_ERROR "lines[] is required"` |
| `payment_method` | yes | `balance` pays from prepaid balance. Other methods return a `payment_url` to redirect the buyer |
| `recipient_data` | conditional | required only for `fragment` / `playwallet` / `manual_gift` products — see “Recipient data” |
| `return_url` | no | redirect target for hosted-payment methods |
| `idempotency_key` | recommended | dedupes retries of the same create; reuse the same value when retrying |

Response `200` (order created; a second object describes payment):
```json
{
  "data": {
    "order_id": "uuid",
    "order_number": "GG-123456",
    "status": "pending",
    "total": 1605,
    "currency": "EUR",
    "payment_id": "uuid",
    "payment_status": "paid",
    "payment_url": "https://…"
  }
}
```
- `total` is **euro-cents** (integer).
- If payment succeeds immediately (e.g. `balance`): `payment_status` set, no `payment_url`.
- If the method needs a hosted page: `payment_url` present — redirect the buyer there.
- If payment setup fails: the body carries `payment_error` (string) instead of `payment_id` (the order still exists).

Order-create errors:
| HTTP | code / body | when |
|---|---|---|
| 400 | `VALIDATION_ERROR` | malformed JSON body |
| 422 | `VALIDATION_ERROR` | `lines[]` empty |
| 422 | `{ "data": { "error": "recipient_required", "recipient_schema": {…} } }` | product needs recipient, none/blank given |
| 422 | `{ "data": { "error": "recipient_invalid", "recipient_schema": {…} } }` | recipient given but fails the field regex |
| 402 | `PHONE_VERIFY_REQUIRED` | buyer account must verify a phone first |

Note the recipient 422 uses the **data**-wrapped shape above (not the standard `error` envelope), and echoes the schema so you can self-correct.

### GET /pub/orders — list your orders

Query: `limit` (1–100, default 20), `since` (RFC3339; invalid → `400 VALIDATION_ERROR`).
Response `200`:
```json
{
  "data": {
    "items": [
      { "id":"uuid", "order_number":"GG-123456", "status":"fulfilled", "total":1605, "currency":"EUR", "created_at":"…" }
    ],
    "limit": 20,
    "total": 57
  }
}
```

### GET /pub/orders/{id} — one order

Response `200`:
```json
{
  "data": {
    "id": "uuid",
    "order_number": "GG-123456",
    "status": "fulfilled",
    "payment_status": "paid",
    "total": 1605,
    "currency": "EUR",
    "items": [
      { "product_id":"uuid", "title":"Steam Top-Up 10 EUR", "unit_price":1605, "quantity":1 }
    ],
    "created_at": "…",
    "updated_at": "…"
  }
}
```
`total` and `unit_price` are **euro-cents**. **This endpoint never returns the purchased keys/codes** — see below. `404 NOT_FOUND` if not yours / unknown.

### GET /pub/balance — prepaid balance

Response `200`:
```json
{ "data": { "balance": 1234.56, "currency": "EUR", "last_deposit_at": "…", "last_updated_at": "…" } }
```
`balance` is **EUR decimal**. This is the pot `payment_method":"balance"` draws from.

### GET /pub/recent-sales — what actually sold

Query: `since` (RFC3339, default last 24h; invalid → `400 BAD_SINCE`), `channel` (`site` | `market`; empty = both; other → `400 BAD_CHANNEL`), `limit` (1–5000, default 1000).
Response `200` (no buyer data — product movement only):
```json
{
  "data": {
    "since": "2026-10-26T00:00:00Z",
    "channel": "",
    "count": 2,
    "sales": [
      { "product_id":"uuid", "title":"…", "channel":"site", "sold_at":"…", "qty":3 }
    ]
  }
}
```

---

## Webhooks

Register HTTP endpoints to receive order lifecycle events (this is how you get delivered keys asynchronously — see below).

**Events** (`events[]` at registration): `order.created`, `order.paid`, `order.delivered`, `order.cancelled`, `order.refunded`.

### POST /pub/webhooks — register
Body: `{ "url": "https://you/hook", "events": ["order.delivered","order.paid"] }`.
Response `201`:
```json
{ "data": { "id":"uuid", "url":"…", "secret":"whsec_…", "events":["…"], "is_active":true, "created_at":"…", "updated_at":"…" } }
```
**Store `secret` now** — it signs every delivery. (It is only returned on create/get of your own endpoint.)

### GET /pub/webhooks — list → `{ "data": { "endpoints": [ …endpoint… ] } }`
### GET /pub/webhooks/{id} — one endpoint
### PATCH /pub/webhooks/{id} — update `{ url, events[], is_active }`
### DELETE /pub/webhooks/{id} — remove
### POST /pub/webhooks/{id}/ping — enqueue a `webhook.ping` test delivery → `202 { "data": { "delivery_id":"uuid", "status":"…" } }`
### GET /pub/webhooks/{id}/deliveries — recent delivery attempts → `{ "data": { "deliveries": [ { "id","event","status","attempts","max_attempts","last_status","last_error","created_at","delivered_at" } ] } }`

### Delivery request we send you

Headers on every webhook POST to your URL:
```
X-GG-Event: order.delivered
X-GG-Delivery-Id: <uuid>        # dedupe retries on this
X-GG-Timestamp: <unix-seconds>
X-GG-Signature: sha256=<hex>
Content-Type: application/json
```

**Verify the signature** (HMAC-SHA256 over `"<timestamp>." + rawBody`, keyed by your endpoint `secret`):
```python
import hmac, hashlib
def valid(secret, ts, raw_body, header_sig):
    mac = hmac.new(secret.encode(), (ts + ".").encode() + raw_body, hashlib.sha256)
    expected = "sha256=" + mac.hexdigest()
    return hmac.compare_digest(expected, header_sig)
```
Reject if `X-GG-Timestamp` is stale (replay protection). Deliveries retry with backoff up to `max_attempts`; dedupe on `X-GG-Delivery-Id`.

**Body** (all order events share the shape; `order.delivered` adds keys):
```json
{
  "event": "order.delivered",
  "order": {
    "id": "uuid",
    "order_number": "GG-123456",
    "status": "fulfilled",
    "total_cents": 1605,
    "currency": "EUR",
    "keys": ["XXXXX-YYYYY-ZZZZZ"],
    "sealed": true,
    "sealed_count": 1
  }
}
```
- `order.keys[]` (only on `order.delivered`): the delivered serial codes.
- `sealed` / `sealed_count` appear when some codes are sealed: those are **not** put in the webhook — retrieve them from the web cabinet vault. If all codes are plain, `keys[]` holds them and `sealed` is absent.
- `total_cents` here is the same integer euro-cents as order `total`.

---

## Key delivery — where the codes are

Three facts, in order of reliability:

1. **`GET /pub/orders/{id}` does NOT return keys.** It returns status only. Polling it tells you *that* an order is `fulfilled`, never *what* was delivered.
2. **The `order.delivered` webhook carries the codes** in `order.keys[]`. This is the intended programmatic delivery path — register a webhook for `order.delivered`.
3. **Sealed codes** (`sealed: true`) and anything you'd rather pull by hand live in the **web cabinet vault** (`GET /api/v1/users/me/vault`, authenticated by a web session, not the API key). Use it as the human fallback.

We issue and send the key — there is no "shown once, store it or lose it" step on your side.

---

## Recipient data

Most products (`delivery_type":"auto"`, plain keys) need **no** `recipient_data`. Three delivery types require exactly one field; the backend validates against a regex and, on failure, echoes the schema back (`recipient_schema`) so you can retry.

| delivery_type | required field | regex | example |
|---|---|---|---|
| `fragment` | `telegram_username` | `^@?[a-zA-Z0-9_]{5,32}$` | `@vvv_support` (leading `@` optional; backend normalizes) |
| `playwallet` | `steam_login` | `^[a-zA-Z0-9_-]{3,32}$` | `dark_slayer_2077` |
| `manual_gift` | `recipient_email` | `^[^@\s]+@[^@\s]+\.[^@\s]+$` | `friend@example.com` |

`recipient_schema` shape (returned in the 422 body):
```json
{ "field":"steam_login", "label":"Steam login получателя", "example":"dark_slayer_2077",
  "validation_regex":"^[a-zA-Z0-9_-]{3,32}$", "hint_ru":"…", "hint_en":"…" }
```
Pass it as, e.g., `"recipient_data": { "steam_login": "dark_slayer_2077" }`.

---

## Error codes (full)

| HTTP | code | meaning |
|---|---|---|
| 400 | `BAD_REQUEST` | malformed path/param (e.g. empty slug) |
| 400 | `VALIDATION_ERROR` | bad query value (e.g. non-RFC3339 `updated_since`/`since`) or malformed JSON |
| 400 | `BAD_SINCE` / `BAD_CHANNEL` | recent-sales: bad `since` / `channel` |
| 401 | `UNAUTHORIZED` | missing or invalid/revoked/expired key |
| 402 | `PHONE_VERIFY_REQUIRED` | buyer account needs phone verification |
| 403 | `TEST_KEY_ON_PROD` / `LIVE_KEY_ON_SANDBOX` | key/environment mismatch |
| 404 | `NOT_FOUND` | product/order unknown or not yours |
| 422 | `VALIDATION_ERROR` | empty `lines[]` |
| 422 | `recipient_required` / `recipient_invalid` | recipient missing / malformed (data-wrapped, with `recipient_schema`) |
| 429 | `RATE_LIMIT` | rate limit hit (see `X-RateLimit-*`) |
| 500 | `INTERNAL` | server-side failure |

---

## MCP endpoint (for AI agents)

An AI agent can drive the catalog and cart over Model Context Protocol instead of raw REST:
`POST https://gaming-goods.ru/api/v1/mcp` (JSON-RPC; `GET` for the handshake). Tools cover search, cart, and checkout, including the same `recipient_schema` contract. Same key, same semantics as above.

---

## Legacy Partner API (separate surface)

Older integrations use `/api/partner/v1/*` (brands/categories catalog, server-side cart, checkout, orders, reconciliation). It is **not** the Buyer API — different auth and shapes. Prefer `/api/v1/pub/*` for new work. One cross-over fact if you read partner responses: catalog products there expose `price`, `platform_fee`, `price_total` with the invariant `price + platform_fee == price_total`, and a `price_unit` field that names the scale explicitly (EUR uses cents, RUB uses whole rubles).

---

## Machine-readable specs

- OpenAPI 3.1 (everything): `GET /api/v1/openapi.json`
- OpenAPI 3.1 (buyer subset): `GET /api/v1/openapi/pub.json`
- Postman collection v2.1: `GET /api/v1/postman.json`
- Redoc UI: https://gaming-goods.ru/api-reference

Get a key — **ceo@vvv.cash**.
