# DBD Merchant API — full documentation Base URL: `https://merchant.dbdlobby.com`. HTTPS and JSON only. Authentication: the `X-Entity-Key` header. Prices are numbers in the product currency (RUB or USD), times are ISO 8601 in UTC. Web version: https://docs.merchant.dbdlobby.com/en. OpenAPI: https://docs.merchant.dbdlobby.com/openapi.json. # Guides ## Authentication Every request except /health/api carries the client key in the X-Entity-Key header. We issue the key when you are onboarded. We store only its hash (SHA-256), so a lost key cannot be recovered — only reissued. ```bash curl https://merchant.dbdlobby.com/products \ -H "X-Entity-Key: YOUR_ENTITY_KEY" ``` > [!WARNING] > The API is meant to be called from your server. Never embed the key in browser or mobile code: anyone holding it can spend your balance. ### Authorization errors | Code | message | Cause | |---|---|---| | 401 | `Missing X-Entity-Key header` | The header is missing or empty. | | 401 | `Invalid X-Entity-Key header` | The value is longer than 128 characters. | | 403 | `Invalid entity key` | No such key. | | 403 | `API access disabled for this client` | API access is disabled for the client. Contact us. | The sandbox (`/sandbox/*`) accepts the same key — you don't need a separate test key. ## Purchases and order status How a purchase works, when money is charged and returned, and where to get the key. ### Steps 1. Optionally check availability right before buying: [GET /products/{id}/availability](https://docs.merchant.dbdlobby.com/en/reference/product-availability). 2. [POST /purchase/{productId}](https://docs.merchant.dbdlobby.com/en/reference/purchase) with an `idempotencyKey`. The order is created and its price reserved from the balance in one operation. If the balance is short, no order is created (`402`). 3. A manually fulfilled product completes immediately. An automatically fulfilled product is bought from the supplier; after the key arrives the order is checked for a short while and only then becomes `completed`. 4. The key is in the `key` field of the purchase response or [GET /orders/{id}](https://docs.merchant.dbdlobby.com/en/reference/get-order). Webhooks never contain it. ### Statuses | status | Meaning | Money | |---|---|---| | `pending` | In progress. Wait for the webhook or poll the order every 5–10 seconds. | Reserved | | `completed` | Fulfilled, the key is available. | Charged | | `failed` | Not fulfilled, see `failureReason`. | Returned | | `cancelled` | Cancelled by the supplier. | Returned | `completed`, `failed` and `cancelled` are final: the status never changes again. ### The 202 response If the supplier purchase doesn't finish within about 50 seconds, the API answers `202` with the order in `pending`. This is not an error and not a reason to buy again: the order keeps going. Learn the outcome from the webhook or `GET /orders/{id}`. In rare cases, when the outcome has to be double-checked, the order stays `pending` longer — the money stays reserved all that time and comes back if the order is not fulfilled. > [!NOTE] > Set your HTTP client timeout to at least 90 seconds: a purchase can take up to ~50 seconds. ## Idempotency How to safely retry a purchase after a timeout or a dropped connection. If the response to `POST /purchase` never arrived, you don't know whether the purchase went through. An unprotected retry is a second purchase and a second charge. So send an `idempotencyKey` — your own unique purchase identifier, for example the order ID in your system. - The first request with a key creates the order. - A repeat with the same key and the same product returns the outcome of that same order — with the same status code and `"replayed": true`. Nothing is charged twice, even if the product has sold out since. - The same key with a different product — `422 idempotency_key_reused`. - If no order was created (e.g. `402`, insufficient balance), the key stays free: after a top-up you can retry with it. ### Format 1–128 printable ASCII characters (`!`…`~`), no spaces or non-Latin letters. Case matters: `ABC` and `abc` are two different purchases. A key is scoped to your client. ### Retrying with the same key ```javascript async function buy(productId, idempotencyKey) { for (let attempt = 1; attempt <= 5; attempt++) { try { const res = await fetch(`https://merchant.dbdlobby.com/purchase/${productId}`, { method: "POST", headers: { "X-Entity-Key": KEY, "Content-Type": "application/json" }, body: JSON.stringify({ idempotencyKey }), // the same key in every attempt signal: AbortSignal.timeout(90_000), }); if (res.status >= 500 && res.status !== 502) throw new Error(`HTTP ${res.status}`); return { status: res.status, body: await res.json() }; } catch (e) { // Network, timeout or 5xx: the outcome is unknown — retry WITH THE SAME key. await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt)); } } throw new Error("API unavailable; retry later with the same idempotencyKey"); } ``` The key is optional, but without it a retry after a timeout creates a second purchase. It may become mandatory in the future. ## Webhooks A notification to your server when an order reaches a final status. Pass `webhookUrl` in the `POST /purchase` body. When the order becomes `completed`, `failed` or `cancelled`, we send a POST request to that URL: ```json { "event": "order.status", "orderId": 1842, "status": "completed", "failureReason": null, "productId": 12, "price": 1490, "currency": "RUB", "idempotencyKey": "order-7731", "createdAt": "2026-11-13T12:36:53.000Z" } ``` The webhook does not contain the product key — get it via [GET /orders/{orderId}](https://docs.merchant.dbdlobby.com/en/reference/get-order). ### Headers | Header | Value | |---|---| | `X-Merchant-Timestamp` | Send time, Unix seconds. | | `X-Merchant-Signature` | `sha256=` + hex HMAC-SHA256 of the string `.` with your webhook secret. The secret is issued together with your access key. | | `User-Agent` | `merchant-webhooks/1` | ### Verifying the signature Always check the signature and the timestamp — otherwise anyone can send you a “paid” order. ```javascript import crypto from "node:crypto"; // rawBody is the request body as a string, BEFORE JSON.parse: the signature covers the exact bytes. export function verifyMerchantWebhook(rawBody, headers, secret) { const timestamp = headers["x-merchant-timestamp"]; const signature = headers["x-merchant-signature"] ?? ""; // Drop old deliveries — protects against replaying an intercepted request. if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; const expected = "sha256=" + crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex"); const a = Buffer.from(signature); const b = Buffer.from(expected); return a.length === b.length && crypto.timingSafeEqual(a, b); } // Express: // app.post("/hooks/merchant", express.raw({ type: "application/json" }), (req, res) => { // if (!verifyMerchantWebhook(req.body.toString("utf8"), req.headers, process.env.MERCHANT_WEBHOOK_SECRET)) // return res.sendStatus(401); // const event = JSON.parse(req.body); // res.sendStatus(200); // answer fast, queue the processing // }); ``` ```python import hashlib import hmac import time def verify_merchant_webhook(raw_body: bytes, headers, secret: str) -> bool: timestamp = headers.get("X-Merchant-Timestamp", "") signature = headers.get("X-Merchant-Signature", "") if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300: return False message = timestamp.encode() + b"." + raw_body expected = "sha256=" + hmac.new(secret.encode(), message, hashlib.sha256).hexdigest() return hmac.compare_digest(signature, expected) ``` ### Delivery and retries - A `2xx` response within 5 seconds means delivered. Answer right away and queue the processing. - Network error, timeout, `5xx`, `408`, `429` — retried after 30 s, 2 min, 10 min, 30 min, 2 h and 6 h (up to 7 attempts in total). - Redirects (`3xx`) are not followed; other `4xx` are a final refusal with no retries. - The result is in the order's `webhookStatus` field: `pending`, `delivered` or `failed`. - One event can arrive more than once. Process idempotently by the `orderId` + `status` pair. ### URL requirements `https://` only, up to 2048 characters, no username or password in the URL. No `localhost`, internal or reserved IP addresses — neither directly nor through a DNS name pointing at them. An invalid URL gives `400 Invalid webhookUrl (reason)`. In the sandbox a webhook is sent only for a successful test purchase, contains `"sandbox": true` and is retried up to 3 times. ## Errors Error format, response codes and what to do about them. All responses are JSON. Successful ones contain `"status": "success"`, errors contain `"status": "error"` and `message`; some errors also have a machine-readable `code`. ```json { "status": "error", "code": "idempotency_key_reused", "message": "This idempotencyKey was already used for a different product" } ``` ### Response codes | Code | When | What to do | |---|---|---| | 400 | Invalid ID, body or parameter. | Fix the request. | | 401 / 403 | No key, invalid key, access or purchases disabled. | See “Authentication”. | | 402 | Insufficient balance. No order created. | Top up and retry with the same key. | | 404 | Product or order not found. | Check the ID. | | 409 | The product can't be purchased. | Refresh the catalog, check availability. | | 413 / 415 | Body over 10 KB or not JSON. | Fix the request. | | 422 | idempotencyKey used for a different product. | Use a new key for a new purchase. | | 429 | Rate limit exceeded. | Wait and slow down. | | 500 | Internal error or `key_unavailable`. | Retry with the same idempotencyKey / fetch the order later. | | 502 | The supplier didn't fulfill the order (`failed`). Money returned. | You may buy again with a new key. | | 503 | Temporarily unavailable or maintenance (`maintenance_mode`). | Retry later. | ### Failure reasons (failureReason) Returned in the purchase response `message` and in the `failureReason` field of a `failed` or `cancelled` order. | failureReason | Meaning | |---|---| | `insufficient_balance` | Insufficient balance. | | `purchase_disabled` | Purchases are disabled for the client. | | `product_unavailable` | The product can't be sold now: out of stock, or the supplier's price is outside the allowed range. | | `fulfillment_failed` | The supplier didn't fulfill the order. | | `fulfillment_cancelled` | The supplier cancelled or refunded the order. | | `client_not_found` | Client not found. | The list may grow: treat an unknown value as a generic failure. ## Limits How many requests you can make and what limits apply to the data. | Limit | Value | |---|---| | Requests from one IP (all except /health/api) | 600 per 15 minutes | | Requests from one client (by key) | 150 per 15 minutes | | Sandbox from one IP, on top | 30 per 15 minutes | | Request body | 10 KB, application/json only | | X-Entity-Key · idempotencyKey · webhookUrl | 128 · 128 · 2048 characters | Exceeding a limit gives `429 Too many requests, try again later`. The window is fixed: the counter resets 15 minutes after the first request in the window. The values may change — don't build logic on the exact numbers. ### Staying under the limit - Cache the catalog: `GET /products` is refreshed only every 10 minutes on our side anyway. - Wait for the webhook instead of polling orders; if you poll, no more than once every 5–10 seconds. - Don't check availability of every product in a row — only the one you're buying. ### Blocking Repeated requests to non-existent paths without a valid key are treated as scanning: the IP is temporarily blocked (`403 IP banned`). ### Maintenance During maintenance every request except `/health/api` gets `503` with `"code": "maintenance_mode"` and the header `X-Maintenance-Mode: 1`. Retry later. # Endpoint reference ## Catalog ### GET /products — List products The whole catalog with prices and an “on sale” flag. Returns every catalog product, including ones taken off sale. Filter them yourself by `isAvailable`. The response comes from a cache refreshed every 10 minutes, so a price change can show up here with a delay. Fresh data for one product: `GET /products/{id}/info`; a live stock check right before buying: `GET /products/{id}/availability`. Authorization: header `X-Entity-Key`. **Responses** - `200 OK` — Catalog ```json { "status": "success", "products": [ { "id": 12, "title": "Dead by Daylight — Steam Key", "price": 1490, "currency": "RUB", "region": "ru", "platform": "steam", "isAvailable": true, "images": { "42x42": "https://cdn.example.com/p/12-42.png", "156x156": "https://cdn.example.com/p/12-156.png", "512x512": null } }, { "id": 13, "title": "Dead by Daylight — Chapter DLC", "price": 590, "currency": "RUB", "region": "ru", "platform": "steam", "isAvailable": false, "images": { "42x42": "https://cdn.example.com/p/12-42.png", "156x156": "https://cdn.example.com/p/12-156.png", "512x512": null } } ] } ``` - `401 Unauthorized` — No key ```json { "status": "error", "message": "Missing X-Entity-Key header" } ``` - `403 Forbidden` — Invalid key ```json { "status": "error", "message": "Invalid entity key" } ``` - `429 Too Many Requests` — Rate limited ```json { "status": "error", "message": "Too many requests, try again later" } ``` ### GET /products/{id}/info — Product info One product: title, price, currency, region, platform, status, images. Read from the database on every request, without a cache — changes are visible immediately. `isAvailable` is our own “on sale” flag. It does not guarantee the supplier has the product right now — use `GET /products/{id}/availability` for that. Authorization: header `X-Entity-Key`. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `id` | integer | yes | Product ID, a positive integer. Example: `12`. | **Responses** - `200 OK` — Product ```json { "status": "success", "product": { "id": 12, "title": "Dead by Daylight — Steam Key", "price": 1490, "currency": "RUB", "region": "ru", "platform": "steam", "isAvailable": true, "images": { "42x42": "https://cdn.example.com/p/12-42.png", "156x156": "https://cdn.example.com/p/12-156.png", "512x512": null } } } ``` - `400 Bad Request` — Invalid ID ```json { "status": "error", "message": "Invalid product id" } ``` - `404 Not Found` — Product not found ```json { "status": "error", "message": "Product not found" } ``` - `401 Unauthorized` — No key ```json { "status": "error", "message": "Missing X-Entity-Key header" } ``` - `403 Forbidden` — Invalid key ```json { "status": "error", "message": "Invalid entity key" } ``` - `429 Too Many Requests` — Rate limited ```json { "status": "error", "message": "Too many requests, try again later" } ``` ### GET /products/{id}/availability — Check availability A live check: can the product be bought right now. For automatically fulfilled products it checks the supplier's stock and whether the price is acceptable. For manually fulfilled products it returns our own “on sale” flag. Call it right before a purchase. `POST /purchase/{productId}` runs the same check, so this endpoint and the purchase never disagree. Authorization: header `X-Entity-Key`. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `id` | integer | yes | Product ID, a positive integer. Example: `12`. | **Responses** - `200 OK` — Available ```json { "status": "success", "productId": 12, "price": 1490, "currency": "RUB", "available": true } ``` - `200 OK` — Unavailable. `reason`: `out_of_stock` — the supplier has none; `not_configured` — the product isn't set up for automatic fulfillment; `temporarily_unavailable` — can't be sold at the current price for now. ```json { "status": "success", "productId": 12, "price": 1490, "currency": "RUB", "available": false, "reason": "out_of_stock" } ``` - `400 Bad Request` — Invalid ID ```json { "status": "error", "message": "Invalid product id" } ``` - `404 Not Found` — Product not found ```json { "status": "error", "message": "Product not found" } ``` - `503 Service Unavailable` — Check unavailable ```json { "status": "error", "message": "Availability check temporarily unavailable" } ``` - `401 Unauthorized` — No key ```json { "status": "error", "message": "Missing X-Entity-Key header" } ``` - `403 Forbidden` — Invalid key ```json { "status": "error", "message": "Invalid entity key" } ``` - `429 Too Many Requests` — Rate limited ```json { "status": "error", "message": "Too many requests, try again later" } ``` ## Purchases and orders ### POST /purchase/{productId} — Purchase a product Creates an order, charges its price to the balance and returns the key. The price is reserved from the balance when the order is created. If the order ends up not fulfilled (`failed`/`cancelled`), the money returns to the balance automatically. A manually fulfilled product completes immediately (`201`). An automatically fulfilled product is bought from the supplier: usually a few seconds, but if it takes longer than ~50 seconds you get `202` with status `pending` — the order keeps going. Learn the outcome from the webhook or `GET /orders/{id}`. Always send an `idempotencyKey`. If the connection dropped or timed out, repeat the request with the same key: you get the outcome of the original order, not a second purchase. Authorization: header `X-Entity-Key`. **Charges the balance.** **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `productId` | integer | yes | Product ID from the catalog. Example: `12`. | **Request body (JSON, up to 10 KB)** | Name | Type | Required | Description | |---|---|---|---| | `idempotencyKey` | string | no | Your unique purchase key: 1–128 printable ASCII characters, no spaces, case-sensitive. A repeat with the same key creates no second order and charges nothing twice — it returns the outcome of the first request with `replayed: true`. Strongly recommended (may become mandatory). Example: `order-7731`. | | `webhookUrl` | string (URL) | no | Where to send the notification about the order outcome. https only, up to 2048 characters, no username/password in the URL, not localhost or internal addresses. Example: `https://shop.example.com/hooks/merchant`. | **Responses** - `201 Created` — Purchased. `key` is the supplier's array of keys; the key itself is in `serial`, `type` is `text` or an image. Manually fulfilled products have no `key` field. ```json { "status": "success", "order": { "id": 1842, "productId": 12, "quantity": 1, "price": 1490, "currency": "RUB", "status": "completed", "failureReason": null, "idempotencyKey": "order-7731", "webhookStatus": "pending", "createdAt": "2026-11-13T12:36:53.000Z", "updatedAt": "2026-11-13T12:37:10.000Z", "key": [ { "serial": "ABCDE-FGHIJ-KLMNO", "type": "text", "name": "Dead by Daylight" } ] } } ``` - `202 Accepted` — In progress. The order is created and the money reserved; fulfillment is still running. Wait for the webhook or poll `GET /orders/{id}`. ```json { "status": "pending", "message": "Purchase is still being processed", "order": { "id": 1842, "productId": 12, "quantity": 1, "price": 1490, "currency": "RUB", "status": "pending", "failureReason": null, "idempotencyKey": "order-7731", "webhookStatus": null, "createdAt": "2026-11-13T12:36:53.000Z", "updatedAt": "2026-11-13T12:37:10.000Z" } } ``` - `201 Created` — Replayed request. Same `idempotencyKey` — the outcome of the first request is returned, nothing is charged again. The status code matches the order's outcome. ```json { "status": "success", "order": { "id": 1842, "productId": 12, "quantity": 1, "price": 1490, "currency": "RUB", "status": "completed", "failureReason": null, "idempotencyKey": "order-7731", "webhookStatus": "delivered", "createdAt": "2026-11-13T12:36:53.000Z", "updatedAt": "2026-11-13T12:37:10.000Z" }, "replayed": true } ``` - `400 Bad Request` — Bad request. Also: `Invalid idempotencyKey (1-128 printable ASCII characters, no spaces)`, `Invalid webhookUrl (must_be_https)`, `Malformed JSON body`. ```json { "status": "error", "message": "Invalid product id" } ``` - `402 Payment Required` — Insufficient balance. No order is created and nothing is charged. After a top-up you can repeat with the same `idempotencyKey`. ```json { "status": "error", "message": "insufficient_balance" } ``` - `403 Forbidden` — Purchases disabled ```json { "status": "error", "message": "purchase_disabled" } ``` - `404 Not Found` — Product not found ```json { "status": "error", "message": "Product not found" } ``` - `409 Conflict` — Product unavailable ```json { "status": "error", "message": "Product is not available for purchase" } ``` - `415 Unsupported Media Type` — Not JSON ```json { "status": "error", "message": "Content-Type must be application/json" } ``` - `422 Unprocessable Entity` — Key already used. This `idempotencyKey` was already used for a different product. ```json { "status": "error", "code": "idempotency_key_reused", "message": "This idempotencyKey was already used for a different product" } ``` - `502 Bad Gateway` — Fulfillment failed. The supplier did not fulfill the order. The money is returned to the balance. ```json { "status": "error", "message": "fulfillment_failed", "order": { "id": 1842, "productId": 12, "quantity": 1, "price": 1490, "currency": "RUB", "status": "failed", "failureReason": "fulfillment_failed", "idempotencyKey": "order-7731", "webhookStatus": "delivered", "createdAt": "2026-11-13T12:36:53.000Z", "updatedAt": "2026-11-13T12:37:10.000Z" } } ``` - `500 Internal Server Error` — Key temporarily unavailable. The order is paid and fulfilled, but its key can't be read right now. Retry `GET /orders/{id}` later or contact support — do not buy again. ```json { "status": "error", "code": "key_unavailable", "message": "The order is paid but its key can't be retrieved right now; contact support", "order": { "id": 1842, "productId": 12, "quantity": 1, "price": 1490, "currency": "RUB", "status": "completed", "failureReason": null, "idempotencyKey": "order-7731", "webhookStatus": "delivered", "createdAt": "2026-11-13T12:36:53.000Z", "updatedAt": "2026-11-13T12:37:10.000Z" } } ``` - `503 Service Unavailable` — Availability check down ```json { "status": "error", "message": "Availability check temporarily unavailable" } ``` - `401 Unauthorized` — No key ```json { "status": "error", "message": "Missing X-Entity-Key header" } ``` - `403 Forbidden` — Invalid key ```json { "status": "error", "message": "Invalid entity key" } ``` - `429 Too Many Requests` — Rate limited ```json { "status": "error", "message": "Too many requests, try again later" } ``` ### GET /orders/{id} — Get an order Order status, and the key once the order is completed. Returns only your own orders: someone else's order and a missing one both give `404`. The key (`key`) comes only here and in the `POST /purchase` response — never in a webhook. Authorization: header `X-Entity-Key`. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `id` | integer | yes | Order ID, a positive integer. Example: `12`. | **Responses** - `200 OK` — Completed ```json { "status": "success", "order": { "id": 1842, "productId": 12, "quantity": 1, "price": 1490, "currency": "RUB", "status": "completed", "failureReason": null, "idempotencyKey": "order-7731", "webhookStatus": "delivered", "createdAt": "2026-11-13T12:36:53.000Z", "updatedAt": "2026-11-13T12:37:10.000Z", "key": [ { "serial": "ABCDE-FGHIJ-KLMNO", "type": "text", "name": "Dead by Daylight" } ] } } ``` - `200 OK` — Pending ```json { "status": "success", "order": { "id": 1842, "productId": 12, "quantity": 1, "price": 1490, "currency": "RUB", "status": "pending", "failureReason": null, "idempotencyKey": "order-7731", "webhookStatus": null, "createdAt": "2026-11-13T12:36:53.000Z", "updatedAt": "2026-11-13T12:37:10.000Z" } } ``` - `200 OK` — Failed ```json { "status": "success", "order": { "id": 1842, "productId": 12, "quantity": 1, "price": 1490, "currency": "RUB", "status": "failed", "failureReason": "product_unavailable", "idempotencyKey": "order-7731", "webhookStatus": "delivered", "createdAt": "2026-11-13T12:36:53.000Z", "updatedAt": "2026-11-13T12:37:10.000Z" } } ``` - `400 Bad Request` — Invalid ID ```json { "status": "error", "message": "Invalid order id" } ``` - `404 Not Found` — Not found ```json { "status": "error", "message": "Order not found" } ``` - `500 Internal Server Error` — Key temporarily unavailable ```json { "status": "error", "code": "key_unavailable", "message": "The order is paid but its key can't be retrieved right now; contact support", "order": { "id": 1842, "productId": 12, "quantity": 1, "price": 1490, "currency": "RUB", "status": "completed", "failureReason": null, "idempotencyKey": "order-7731", "webhookStatus": "delivered", "createdAt": "2026-11-13T12:36:53.000Z", "updatedAt": "2026-11-13T12:37:10.000Z" } } ``` - `401 Unauthorized` — No key ```json { "status": "error", "message": "Missing X-Entity-Key header" } ``` - `403 Forbidden` — Invalid key ```json { "status": "error", "message": "Invalid entity key" } ``` - `429 Too Many Requests` — Rate limited ```json { "status": "error", "message": "Too many requests, try again later" } ``` ## Sandbox ### GET /sandbox/products — Sandbox products A fixed test catalog. The sandbox mirrors the shape of live responses but never touches the balance, orders or statistics. Any valid `X-Entity-Key` works. Each product deterministically produces one outcome — like test card numbers in payment systems. Authorization: header `X-Entity-Key`. **Responses** - `200 OK` — Sandbox catalog ```json { "status": "success", "sandbox": true, "products": [ { "id": "sandbox-success", "title": "[Sandbox] Always succeeds", "price": 100, "currency": "RUB", "region": "all", "platform": "all", "isAvailable": true, "images": { "42x42": null, "156x156": null, "512x512": null } }, { "id": "sandbox-insufficient-balance", "title": "[Sandbox] Always fails: insufficient balance", "price": 100, "currency": "RUB", "region": "all", "platform": "all", "isAvailable": true, "images": { "42x42": null, "156x156": null, "512x512": null } }, { "id": "sandbox-unavailable", "title": "[Sandbox] Always unavailable", "price": 100, "currency": "RUB", "region": "all", "platform": "all", "isAvailable": false, "images": { "42x42": null, "156x156": null, "512x512": null } } ] } ``` - `401 Unauthorized` — No key ```json { "status": "error", "message": "Missing X-Entity-Key header" } ``` - `403 Forbidden` — Invalid key ```json { "status": "error", "message": "Invalid entity key" } ``` - `429 Too Many Requests` — Rate limited ```json { "status": "error", "message": "Too many requests, try again later" } ``` ### POST /sandbox/purchase/{productId} — Test purchase A simulated purchase with no money. `sandbox-success` → `201`, `sandbox-insufficient-balance` → `402`, `sandbox-unavailable` → `409`, any other ID → `404` with a hint. `idempotencyKey` and `webhookUrl` work as in the live API. A webhook is sent only for a successful order, signed with the same secret and containing `"sandbox": true`; up to 3 attempts. Test orders are kept for 24 hours. The sandbox has its own, stricter rate limit. Authorization: header `X-Entity-Key`. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `productId` | string | yes | Sandbox product ID. Example: `sandbox-success`. | **Request body (JSON, up to 10 KB)** | Name | Type | Required | Description | |---|---|---|---| | `idempotencyKey` | string | no | As in the live API, up to 128 characters. Example: `order-7731`. | | `webhookUrl` | string (URL) | no | Where to send the notification about the order outcome. https only, up to 2048 characters, no username/password in the URL, not localhost or internal addresses. Example: `https://shop.example.com/hooks/merchant`. | **Responses** - `201 Created` — Success ```json { "status": "success", "order": { "id": "sandbox_px8cqEgyiFCA", "clientId": 7, "productId": "sandbox-success", "quantity": 1, "idempotencyKey": "sb-1", "price": 100, "currency": "RUB", "status": "completed", "failureReason": null, "webhookUrl": null, "webhookStatus": null, "webhookAttempts": 0, "sandbox": true, "createdAt": "2026-11-13T12:36:52.979Z", "updatedAt": "2026-11-13T12:36:52.980Z" } } ``` - `201 Created` — Replayed request ```json { "status": "success", "order": { "id": "sandbox_px8cqEgyiFCA", "clientId": 7, "productId": "sandbox-success", "quantity": 1, "idempotencyKey": "sb-1", "price": 100, "currency": "RUB", "status": "completed", "failureReason": null, "webhookUrl": null, "webhookStatus": null, "webhookAttempts": 0, "sandbox": true, "createdAt": "2026-11-13T12:36:52.979Z", "updatedAt": "2026-11-13T12:36:52.980Z" }, "replayed": true } ``` - `402 Payment Required` — Insufficient balance ```json { "status": "error", "message": "insufficient_balance", "order": { "id": "sandbox_LO3g828mKFl_", "clientId": 7, "productId": "sandbox-insufficient-balance", "quantity": 1, "idempotencyKey": null, "price": 100, "currency": "RUB", "status": "failed", "failureReason": "insufficient_balance", "webhookUrl": null, "webhookStatus": null, "webhookAttempts": 0, "sandbox": true, "createdAt": "2026-11-13T12:36:52.979Z", "updatedAt": "2026-11-13T12:36:52.980Z" } } ``` - `409 Conflict` — Unavailable ```json { "status": "error", "message": "Product is not available for purchase" } ``` - `404 Not Found` — Unknown product ```json { "status": "error", "message": "Product not found", "hint": "Sandbox product ids: sandbox-success, sandbox-insufficient-balance, sandbox-unavailable" } ``` - `429 Too Many Requests` — Sandbox rate limit ```json { "status": "error", "message": "Too many sandbox requests, try again later" } ``` - `503 Service Unavailable` — Sandbox unavailable ```json { "status": "error", "message": "Sandbox is temporarily unavailable" } ``` ### GET /sandbox/orders/{id} — Test order A test order by ID (sandbox_…). Only your own test orders, no older than 24 hours. Authorization: header `X-Entity-Key`. **Path parameters** | Name | Type | Required | Description | |---|---|---|---| | `id` | string | yes | Test order ID from the purchase response. Example: `sandbox_px8cqEgyiFCA`. | **Responses** - `200 OK` — Order ```json { "status": "success", "order": { "id": "sandbox_px8cqEgyiFCA", "clientId": 7, "productId": "sandbox-success", "quantity": 1, "idempotencyKey": "sb-1", "price": 100, "currency": "RUB", "status": "completed", "failureReason": null, "webhookUrl": null, "webhookStatus": null, "webhookAttempts": 0, "sandbox": true, "createdAt": "2026-11-13T12:36:52.979Z", "updatedAt": "2026-11-13T12:36:52.980Z" } } ``` - `404 Not Found` — Not found ```json { "status": "error", "message": "Order not found" } ``` ## Service ### GET /health/api — Service health Availability check for monitoring. No authorization. `db` is the database, `redis` the cache. The service works without the cache (`degraded`) but not without the database (`503`). Authorization: not required. **Responses** - `200 OK` — Up ```json { "status": "ok", "timestamp": 1794573411400, "db": "ok", "redis": "ok" } ``` - `200 OK` — No cache ```json { "status": "degraded", "timestamp": 1794573411400, "db": "ok", "redis": "unavailable" } ``` - `503 Service Unavailable` — Down ```json { "status": "error", "timestamp": 1794573411400, "db": "unavailable", "redis": "ok" } ```