# BuyCheapKeys Partner API

> A REST/JSON API to sell Windows and Office keys on your own site. Each order is paid from your prepaid balance and the keys come back in the same response, bought from the cheapest seller with stock at that moment.

- Base URL: `https://buycheapkeys.com/partner-api/v1`
- OpenAPI 3.1: https://buycheapkeys.com/partner-api/v1/openapi.json
- Reference client (Node.js 18+): https://buycheapkeys.com/developers/bck-partner-client.mjs
- HTML version: https://buycheapkeys.com/en/developers · Español: https://buycheapkeys.com/developers.es.md
- Get API keys: https://buycheapkeys.com/en/partners/apply

> **Can't code or don't have a website?** BuyCheapKeys also builds the store for you: a custom key store under your brand and domain, already connected to this API, with payments (card and crypto), emails and automatic delivery working. Price: custom quote, no commitment, based on what you want to build. Ready in 3 to 4 weeks. It is a single-seller store, not a marketplace (a marketplace is quoted separately). 7 languages; others can be added in the quote. Limited edition: only 3 stores will be built; once the 3 spots are taken the service closes. Includes the domain and VPS server for the first year, one day with a developer and the documentation to start selling. Questions: partners@buycheapkeys.com. Request a quote at https://buycheapkeys.com/en/white-label-store

## Contents
- [Overview](#overview)
- [Authentication and environments](#auth)
- [Quick start](#quickstart)
- [Catalogue and synchronisation](#catalog)
- [Orders](#orders)
- [Key delivery](#delivery)
- [Claims and refunds](#final)
- [BuyCheapKeys Gift Cards in bulk](#gift-cards)
- [Webhooks](#webhooks)
- [Errors](#errors)
- [Limits](#limits)
- [Balance and top-ups](#balance)
- [Implementing with an AI assistant / full example](#ai)
- [Security checklist](#security)

<a id="overview"></a>
## Overview

Base URL: `https://buycheapkeys.com/partner-api/v1`. All requests and responses are JSON (UTF-8). Prices are in EUR with two decimals and do not include VAT. Dates are ISO 8601 in UTC.

- Catalogue: list and incrementally sync every product you can sell, with your partner price.
- Orders: buy up to 10 lines × 9 units per request; the total is debited from your balance at once.
- Delivery: the keys are in the response of the order and can be read again with GET /orders/{id}/keys.
- Claims: a faulty key can be claimed for 30 days from your partner dashboard, with messages and screenshots. A replacement key reaches your order automatically and a refund goes back to your balance.

<a id="auth"></a>
## Authentication and environments

Create keys in Dashboard → Partner → API keys. A key is shown **once**; we only store its hash. Send it in the `Authorization` header (`X-Api-Key` is accepted too).

**cURL**

```bash
curl https://buycheapkeys.com/partner-api/v1/ping \
  -H "Authorization: Bearer bck_test_xxxxxxxxxxxx_..."
```

| Key prefix | Mode | Behaviour |
| --- | --- | --- |
| bck_test_ | Sandbox | Real catalogue and prices, separate test balance (resettable), fake keys like TEST-AB12C-…, no money moves. Webhooks are sent with mode: test. |
| bck_live_ | Production | Real purchases charged to your live balance. |

| Scope | Allows |
| --- | --- |
| catalog:read | GET /products, /products/{id}, /meta, /me, /ping, /gift-cards |
| webhooks:write | PUT and DELETE /webhook (register or remove your webhook URL) |
| orders:write | POST /orders, POST /gift-cards/orders |
| orders:read | GET /orders, /orders/{id}, /orders/{id}/keys, /gift-cards/orders, /gift-cards/orders/{id}, POST /webhooks/test |
| balance:read | GET /balance |

> Optional IP allowlist (IPv4/IPv6, CIDR) per account. Keys never expire unless revoked; rotate them by creating a new key and revoking the old one.

<a id="quickstart"></a>
## Quick start

1) Create a `bck_test_` key. 2) List products. 3) Place an order: it comes back completed, with the keys. 4) Read the keys again at any time with GET /orders/{id}/keys. Partner accounts still being approved can use sandbox keys.

**List**

```bash
curl "https://buycheapkeys.com/partner-api/v1/products?limit=100&page=1&inStock=true" \
  -H "Authorization: Bearer $BCK_API_KEY"
```

**Order**

```bash
curl -X POST "https://buycheapkeys.com/partner-api/v1/orders?wait=20" \
  -H "Authorization: Bearer $BCK_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: shop-order-10045" \
  -d '{
    "externalId": "shop-10045",
    "items": [ { "productId": "prod-17298", "qty": 1, "maxUnitPrice": 4.50 } ]
  }'
```

**Keys**

```bash
curl https://buycheapkeys.com/partner-api/v1/orders/po_7f3k2m9q4x8z1c5v6b0n/keys \
  -H "Authorization: Bearer $BCK_API_KEY"
```

<a id="catalog"></a>
## Catalogue and synchronisation

`GET /products` returns up to 100 products ordered by `updatedAt` (then id). `updatedAt` only changes when something you care about changes (price, stock, name, region, texts, images). **Sync with the cursor**: every response has `nextCursor` and `hasMore`; pass `nextCursor` back as `?cursor=` until `hasMore` is false, and keep it for the next run. It never skips or repeats products, even when many share the same timestamp. `page` and `updatedSince` remain available for browsing. Filters: `inStock`, `platform`, `region`, `productType`, `deliveryType`, `search`, `ids` (comma-separated, max 100).

**Incremental**

```bash
curl "https://buycheapkeys.com/partner-api/v1/products?cursor=$SAVED_NEXT_CURSOR&includeRemoved=true&limit=100" \
  -H "Authorization: Bearer $BCK_API_KEY"
```

**Sync loop (Node.js)**

```javascript
let cursor = loadCursor(); // nextCursor saved by the previous run, or null for a full sync
for (;;) {
  const url = new URL("https://buycheapkeys.com/partner-api/v1/products");
  url.searchParams.set("limit", "100");
  url.searchParams.set("includeRemoved", "true");
  if (cursor) url.searchParams.set("cursor", cursor);
  const body = await (await fetch(url, { headers: { Authorization: `Bearer ${KEY}` } })).json();
  for (const p of body.results) {
    if (p.removed || !p.inStock) unpublish(p.id); else upsert(p); // price = p.price (EUR)
  }
  cursor = body.nextCursor;
  saveCursor(cursor); // checkpoint after every page
  if (!body.hasMore) break;
}
```

Recommended: full sync once (no cursor), then every 5–15 minutes starting from the stored `nextCursor`, always with `includeRemoved=true`. A product with `removed: true` or `inStock: false` must not be sold.

**Product**

```json
{
  "id": "prod-17298",
  "name": "Terraria (Steam Gift)",
  "platform": "Steam",
  "region": "Europe",
  "productType": "game",
  "deliveryType": "gift",
  "price": 3.71,
  "currency": "EUR",
  "inStock": true,
  "qty": 12,
  "activation": { "excludedCountries": ["RU", "BY", "..."], "allowedCountryCount": 53 },
  "coverImage": "https://buycheapkeys.com/api/media/catalog/category/.../cover.jpg",
  "screenshots": ["https://buycheapkeys.com/api/media/..."],
  "description": "Dig, Fight, Explore, Build...",
  "activationDetails": "You will receive a gift link...",
  "languages": ["English", "Spanish", "German"],
  "genres": ["Action", "Adventure"],
  "developer": "Re-Logic", "publisher": "Re-Logic", "releaseDate": "2011-05-16", "ageRating": "PEGI 12",
  "systemRequirements": [ { "system": "Windows", "requirement": ["..."] } ],
  "url": "https://buycheapkeys.com/en/product/terraria-steam-europe-238023",
  "updatedAt": "2026-10-01T16:05:12.881Z",
  "removed": false
}
```

| Field | Meaning |
| --- | --- |
| nextCursor / hasMore | Response fields for sync: pass nextCursor as ?cursor= while hasMore is true; store the last one for the next run. |
| price | Your unit price in EUR. Re-checked live when you order. |
| deliveryType | key = code to redeem · gift = Steam Gift link · account = login credentials · link = activation link. Tell your customer which one they buy. |
| region / activation | Region label plus the exact ISO list of countries where it can NOT be activated (null = only the region is known). |
| qty | Units in stock (capped at 999). |
| coverImage / screenshots | Served from our CDN; cache them on your side. |
| activationDetails | Redemption instructions (English) to show your customer. |

<a id="orders"></a>
## Orders

`POST /orders` with `externalId` (your unique reference) and `items` (productId, qty 1–9, maxUnitPrice). For each line we check the live price: if it is above `maxUnitPrice` the whole order is rejected with `PRICE_CHANGED` and nothing is charged. If it is lower, you pay the lower price.

**Node.js**

```javascript
const res = await fetch("https://buycheapkeys.com/partner-api/v1/orders?wait=20", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.BCK_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": `shop-order-${order.id}`, // same value on every retry
  },
  body: JSON.stringify({
    externalId: String(order.id),
    items: [{ productId: "prod-17298", qty: 1, maxUnitPrice: 4.5 }],
  }),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.error.code}: ${data.error.message}`);
if (data.status === "completed") {
  const { keys } = await (await fetch(`https://buycheapkeys.com/partner-api/v1/orders/${data.id}/keys`, {
    headers: { Authorization: `Bearer ${process.env.BCK_API_KEY}` },
  })).json();
  // deliver keys[].value to your customer
}
```

**PHP**

```php
$ch = curl_init('https://buycheapkeys.com/partner-api/v1/orders?wait=20');
curl_setopt_array($ch, [
  CURLOPT_POST => true,
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER => [
    'Authorization: Bearer ' . getenv('BCK_API_KEY'),
    'Content-Type: application/json',
    'Idempotency-Key: shop-order-' . $order['id'],
  ],
  CURLOPT_POSTFIELDS => json_encode([
    'externalId' => (string) $order['id'],
    'items' => [['productId' => 'prod-17298', 'qty' => 1, 'maxUnitPrice' => 4.50]],
  ]),
]);
$data = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
```

**Python**

```python
import os, requests

r = requests.post(
    "https://buycheapkeys.com/partner-api/v1/orders",
    params={"wait": 20},
    headers={"Authorization": f"Bearer {os.environ['BCK_API_KEY']}",
             "Idempotency-Key": f"shop-order-{order_id}"},
    json={"externalId": str(order_id),
          "items": [{"productId": "prod-17298", "qty": 1, "maxUnitPrice": 4.50}]},
    timeout=40,
)
data = r.json()
```

- Send an `Idempotency-Key` header (8–100 chars) and reuse it when you retry: you get the original response and are never charged twice. `externalId` is also unique per account.
- The order is completed in the same request: the response has status `completed` and a `keys` array. A replayed request (same Idempotency-Key) returns the order without its keys and `keysOmitted: true`: read them with GET /orders/{id}/keys.
- If there is no stock or your balance is too low nothing is charged and no key is used (`OUT_OF_STOCK`, `INSUFFICIENT_BALANCE`). `maxUnitPrice` is a ceiling for every key; a line can be split in several items when sellers with different prices are needed.
- If a request times out, send it again with the same externalId: you get `DUPLICATE_EXTERNAL_ID` with the order id and can read its keys.

| Status | Meaning |
| --- | --- |
| completed | All units delivered. The keys are in the response. |
| partially_refunded | Some keys were refunded to your balance after claims. |
| refunded | Every key of the order was refunded to your balance. |

<a id="delivery"></a>
## Key delivery

`GET /orders/{id}/keys` returns every delivered unit: `value` holds the code, the Steam Gift URL, the account credentials or the activation link depending on `deliveryType`. `contentType` is `text/plain` or an image type (then `value` is base64).

> Store keys encrypted and show them only to the buyer. The first download of each order is logged on our side as delivery evidence.

<a id="final"></a>
## Claims and refunds

Each key comes from a seller who is paid after a holding period, so a faulty key can be claimed for **30 days**. Open the order in Dashboard → Orders (API orders), pick the key, write to the seller and attach screenshots of the activation error. The seller can send a replacement key (it appears in your order and you get the `key.replaced` webhook) or refund the key (the price returns to your API balance once and you get `order.refunded`). If you do not agree, escalate and BuyCheapKeys decides.

Check the region, `excludedCountries` and the licence type before selling, and show them to your customer. A refund to your balance does not refund your own customer: that is your decision.

<a id="gift-cards"></a>
## BuyCheapKeys Gift Cards in bulk

Buy our own gift cards to resell: codes `BCK-XXXX-XXXX-XXXX-XXXX` that add their value to the BuyCheapKeys Wallet of whoever redeems them at buycheapkeys.com/wallet. `GET /gift-cards` returns the values on sale, the volume discounts, the maximums per order and your Wallet balance. `POST /gift-cards/orders` buys N cards of one value and returns the codes at once; `GET /gift-cards/orders/{id}` shows them again with the status of each code.

**Offer**

```bash
curl https://buycheapkeys.com/partner-api/v1/gift-cards \
  -H "Authorization: Bearer $BCK_API_KEY"
```

**Buy**

```bash
curl -X POST https://buycheapkeys.com/partner-api/v1/gift-cards/orders \
  -H "Authorization: Bearer $BCK_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: gc-2026-0001" \
  -d '{ "externalId": "gc-2026-0001", "denomination": 25, "quantity": 10, "maxTotal": 247.50 }'
```

**Response**

```json
{
  "id": "gcp_3f9a1c0b7e2d4a6b8c5e",
  "externalId": "gc-2026-0001",
  "mode": "live",
  "status": "completed",
  "currency": "EUR",
  "denomination": 25,
  "quantity": 10,
  "faceValue": 250,
  "discountPct": 1,
  "total": 247.5,
  "paidFrom": "wallet",
  "createdAt": "2026-10-29T10:12:03.114Z",
  "cards": [
    { "id": "gc_8d1f0a2b3c4e5f60", "code": "BCK-7KQ2-M9XA-4HPZ-R3TW", "last4": "R3TW", "value": 25, "status": "active", "redeemedAt": null, "expiresAt": "2028-10-29T10:12:03.114Z" },
    "... 9 more"
  ]
}
```

- Paid only from the **BuyCheapKeys Wallet** of your shop account (top it up at buycheapkeys.com/wallet), not from the API balance. Price = face value minus the volume discount for that quantity.
- Values, discounts and maximums can change: send `maxTotal` to refuse a higher total (`PRICE_CHANGED`, nothing charged). `Idempotency-Key` and the unique `externalId` work as for orders.
- Sale paused: `PRODUCT_UNAVAILABLE` (409). Value not sold or quantity out of range: `VALIDATION_ERROR`. Order too large: `ORDER_LIMIT_EXCEEDED`. Not enough Wallet balance: `INSUFFICIENT_BALANCE` (402).
- Sandbox keys return fake `BCK-TEST-…` codes that cannot be redeemed and charge nothing.
- Sold codes are final. Each code has an expiry date (`expiresAt`) and redemption limits per account. A code we block for fraud is no longer shown (`code: null`, status `blocked`). Store the codes encrypted.

<a id="webhooks"></a>
## Webhooks

Set an https URL in Dashboard → Webhooks; you get a signing secret (`whsec_…`). Every event is a POST with headers `X-BCK-Event`, `X-BCK-Delivery` (unique id — de-duplicate on it) and `X-BCK-Signature: t=<unix>,v1=<hex>` where v1 = HMAC-SHA256(secret, `t + "." + rawBody`).

| Event | When |
| --- | --- |
| order.completed | An order was completed. |
| order.refunded | A claim ended in a refund to your balance (`refundedSale`, `amount`). |
| product.updated | Price, stock or sheet of a product changed (`data.product` has the full product, `removed: true` when it is retired). |
| key.replaced | A seller replaced a key: `oldKeyId`, `newKeyId`; read the new key with GET /orders/{id}/keys. |
| balance.credited | A top-up was added to your balance. |
| webhook.test | Sent by POST /webhooks/test or the dashboard button. |

**Payload**

```json
{
  "id": "evt_4m2k9q7x1z8c3v5b6n0p",
  "event": "order.completed",
  "mode": "live",
  "createdAt": "2026-10-01T16:20:31.512Z",
  "data": { "id": "po_7f3k2m9q4x8z1c5v6b0n", "externalId": "shop-10045", "status": "completed", "total": 3.71, "...": "same object as GET /orders/{id}" }
}
```

**Verify (Node.js)**

```javascript
import crypto from "node:crypto";

// rawBody must be the exact bytes received (do not re-serialise the JSON)
export function verifyBckWebhook(secret, rawBody, header) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const given = String(parts.v1 || "");
  return expected.length === given.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(given));
}
```

**Verify (PHP)**

```php
$payload = file_get_contents('php://input');
$header  = $_SERVER['HTTP_X_BCK_SIGNATURE'] ?? '';
$parts = [];
foreach (explode(',', $header) as $kv) { [$k, $v] = array_pad(explode('=', $kv, 2), 2, ''); $parts[$k] = $v; }
$t = (int) ($parts['t'] ?? 0);
$expected = hash_hmac('sha256', $t . '.' . $payload, getenv('BCK_WEBHOOK_SECRET'));
if (abs(time() - $t) > 300 || !hash_equals($expected, $parts['v1'] ?? '')) {
    http_response_code(400);
    exit;
}
$event = json_decode($payload, true);
// de-duplicate with $_SERVER['HTTP_X_BCK_DELIVERY'], then process
http_response_code(204);
```

**Verify (Python)**

```python
import hmac, hashlib, time

def verify_bck_webhook(secret: str, raw_body: bytes, header: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    t = int(parts.get("t", "0"))
    if abs(time.time() - t) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))
```

- Answer 2xx within 8 s and do the work asynchronously. Redirects are not followed.
- Failed deliveries are retried after 10 s, 30 s, 1 min, 5 min, 15 min, 30 min, 1 h, 2 h, 4 h, 6 h and 12 h (~26 h).
- Events may arrive out of order or more than once: always re-read GET /orders/{id} when in doubt.

<a id="errors"></a>
## Errors

Errors use HTTP status codes and a stable `code`. Quote `requestId` when contacting support.

**Error**

```json
{
  "error": {
    "code": "PRICE_CHANGED",
    "message": "The price of prod-17298 is now 3.95 EUR, above your maxUnitPrice.",
    "details": { "productId": "prod-17298", "currentPrice": 3.95 },
    "requestId": "req_k3m9x2q8z7c1v4b6"
  }
}
```

| code | HTTP | Meaning | What to do |
| --- | --- | --- | --- |
| UNAUTHORIZED | 401 | Missing, invalid, revoked or expired key. | Check the key; create a new one in the dashboard. |
| ACCOUNT_NOT_ACTIVE | 403 | Partner account pending, suspended or rejected. | Contact us. |
| IP_NOT_ALLOWED | 403 | Request IP not in your allowlist. | Add the IP in Dashboard → Webhooks & security. |
| INSUFFICIENT_SCOPE | 403 | The key lacks the scope for this endpoint. | Create a key with the scope. |
| NOT_FOUND | 404 | Unknown product, order or route. | — |
| VALIDATION_ERROR | 400 | Invalid body or parameters (details lists each field). | Fix the request; do not retry as is. |
| INSUFFICIENT_BALANCE | 402 | Balance too low. Nothing was charged. | Top up and retry. |
| PRICE_CHANGED | 409 | Live unit price above maxUnitPrice. Nothing was charged. | Re-price (details.currentPrice) and retry. |
| OUT_OF_STOCK | 409 | Not enough units available. Nothing was charged. | Retry later or hide the product. |
| PRODUCT_UNAVAILABLE | 409 | Product not sellable through the API. | Remove it from your shop. |
| DUPLICATE_EXTERNAL_ID | 409 | externalId already used; details.orderId is the existing order. | Fetch that order instead. |
| IDEMPOTENCY_CONFLICT | 409 | Idempotency-Key reused with a different body. | Use a new key per order. |
| IDEMPOTENCY_IN_PROGRESS | 409 | Same Idempotency-Key still being processed. | Retry in a few seconds. |
| ORDER_LIMIT_EXCEEDED | 422 | Order total above your per-order limit. | Split the order or ask for a higher limit. |
| DAILY_LIMIT_EXCEEDED | 422 | 24-hour spend limit reached. | Wait or ask for a higher limit. |
| RATE_LIMITED | 429 | Too many requests. | Back off (details.retryAfterMs). |
| SERVICE_UNAVAILABLE | 503 | Temporarily unable to sell. Nothing was charged. | Retry with exponential backoff. |
| INTERNAL_ERROR | 500 | Unexpected error (logged with requestId). | Retry with the same Idempotency-Key. |

<a id="limits"></a>
## Limits

| Limit | Value |
| --- | --- |
| Requests per key | 20 / second and 600 / minute |
| Orders per account | 120 / minute |
| Key downloads per account | 300 / minute |
| Lines per order / units per line | 10 / 9 |
| Order value and 24-hour spend | Per account (see Dashboard → Overview); ask us to raise them |
| Page size | 100 products or orders |

<a id="balance"></a>
## Balance and top-ups

`GET /balance` returns the balance of the key's mode. Top up from Dashboard → Balance: you receive a unique reference to include in the bank transfer; the amount is credited when it arrives (e-mail + `balance.credited` webhook). Every movement appears in the ledger.

<a id="ai"></a>
## Implementing with an AI assistant / full example

Machine-readable docs: `https://buycheapkeys.com/developers.md` (this page as Markdown), `https://buycheapkeys.com/developers.es.md` (Spanish), the OpenAPI 3.1 spec `https://buycheapkeys.com/partner-api/v1/openapi.json` and `https://buycheapkeys.com/llms.txt`. Give these URLs to your AI assistant together with the prompt below.

Complete reference integration (Node.js 18+, no dependencies): `https://buycheapkeys.com/developers/bck-partner-client.mjs` — catalogue sync with cursor, safe ordering with retries and idempotency, key download and a webhook receiver with signature verification.

**Run the example**

```bash
curl -O https://buycheapkeys.com/developers/bck-partner-client.mjs
export BCK_API_KEY=bck_test_...   # sandbox key from Dashboard → Partner → API keys
node bck-partner-client.mjs balance
node bck-partner-client.mjs sync
node bck-partner-client.mjs order <productId> 1 <maxUnitPrice>
```

**Prompt for your AI**

```
You are integrating the BuyCheapKeys Partner API into my shop.
Read https://buycheapkeys.com/developers.md and the OpenAPI spec https://buycheapkeys.com/partner-api/v1/openapi.json first,
and use https://buycheapkeys.com/developers/bck-partner-client.mjs as the reference implementation.
My stack: <framework / language / database>.
Build:
1. A scheduled job that syncs GET /products into my product table with the cursor (loop on
   nextCursor until hasMore=false, store the cursor, resume from it every 10 min, includeRemoved=true); unpublish products with inStock=false or removed=true;
   my selling price = price × (1 + <my margin>%).
2. On every paid customer order, call POST /orders with externalId = my order id, the same value as
   Idempotency-Key, maxUnitPrice = the price I synced, and ?wait=20.
3. Handle PRICE_CHANGED / OUT_OF_STOCK / INSUFFICIENT_BALANCE as final (refund or notify the customer),
   retry only network errors, 429, 5xx and IDEMPOTENCY_IN_PROGRESS with backoff.
4. A webhook endpoint that verifies X-BCK-Signature, de-duplicates on X-BCK-Delivery and, on
   order.completed / order.partially_completed, downloads GET /orders/{id}/keys and e-mails them.
5. Show the customer the deliveryType (key, Steam Gift link, account, activation link) and the region.
Keep BCK_API_KEY and BCK_WEBHOOK_SECRET in environment variables. Test everything with a bck_test_ key.
```

- Invariant 1 — sell only products with inStock=true and removed=false; re-sync every 5–15 min from the stored nextCursor.
- Invariant 2 — one customer order = one externalId = one Idempotency-Key; reuse both on every retry.
- Invariant 3 — always send maxUnitPrice; 4xx business errors are final and nothing was charged.
- Invariant 4 — retry only network errors, 429, 5xx and IDEMPOTENCY_IN_PROGRESS, with exponential backoff.
- Invariant 5 — never re-order after a timeout: repeat the same externalId and read the keys of the existing order.
- Invariant 6 — keep the API key server-side; verify webhook signatures; show customers the deliveryType and activation region.

<a id="security"></a>
## Security checklist

- Keep API keys on your server only (never in browsers or apps) and in a secret manager / environment variables.
- Use separate keys per system and the minimum scopes; enable the IP allowlist.
- Always send maxUnitPrice and an Idempotency-Key.
- Verify every webhook signature and timestamp before trusting it.
- Rotate keys and the webhook secret if you suspect a leak, then revoke the old ones.
