GameSend

Документация

API

Подключите наш каталог к своему магазину. Все ответы в JSON, кодировка UTF-8, цены в долларах и уже с вашей наценкой.

Базовый адрес

Все пути ниже отсчитываются от него.

Ключ выдаётся вместе с проектом в личном кабинете.

Получить доступ

Авторизация

Ключ передаётся в заголовке. Он привязан к проекту: каталог и цены вы получите именно те, что настроены в нём.

Заголовок
Authorization: Bearer gs_live_ваш_ключ

Ключ лежит в карточке проекта, там же его можно перевыпустить. Не кладите его в код фронтенда — запросы к нам делает только ваш сервер.

Подробности

Ограничения: 120 запросов в минуту на чтение и 20 в минуту на создание заказов, отдельно для каждого проекта. При превышении придёт 429 с заголовкомRetry-After— он говорит, через сколько секунд повторить. Заказы ограничены строже намеренно: цикл там тратит деньги, и в отличие от чтения это не отменить ожиданием.

Работает сейчас

Эти методы доступны и отвечают.

GET/ping

Проверка ключа

Первое, что стоит вызвать при подключении: подтверждает, что ключ рабочий, и возвращает аккаунт, к которому он привязан. Поле project — то же самое под старым именем, оставлено для уже написанных интеграций.

Запрос
curl https://gamesend.shop/api/v1/ping \
  -H "Authorization: Bearer gs_live_ваш_ключ"
Ответ
{
  "ok": true,
  "mode": "live",
  "project": { "id": "prj_XXXXXXXX", "name": "Мой магазин" }
}

Подробности

mode — live или test. Проверяйте его при старте: ключ, попавший не в тот конфиг, молчит ровно до момента, когда заказ либо не придёт, либо потратит настоящие деньги.

GET/catalog

Каталог

Только те товары, что включены в вашем проекте, и только те, что есть в наличии. Цены уже с вашей наценкой.

Параметры

category
Категория: game_keys, gift_cards, topups
search
Поиск по названию
page
Страница, с 1
per_page
Размер страницы, до 200. По умолчанию 50
include
offers — вложить номиналы прямо в список
Запрос
curl "https://gamesend.shop/api/v1/catalog?category=game_keys&per_page=2" \
  -H "Authorization: Bearer gs_live_ваш_ключ"
Ответ
{
  "items": [
    {
      "id": 1042,
      "name": "Abyssus",
      "category": "game_keys",
      "category_label": "Ключи игр",
      "region": "GLOBAL",
      "image": "https://…/abyssus.jpg",
      "in_stock": 1,
      "cost_from": 17.14,
      "cost_to": 17.14,

      // только с ?include=offers
      "offers": [
        { "id": 5312, "name": "Standard Edition", "cost": 17.14,
          "stock": null, "min_qty": 1, "max_qty": null }
      ]
    }
  ],
  "page": 1,
  "per_page": 2,
  "has_more": true
}

Подробности

cost_* — сколько платите нам вы, price_* — то же с вашей наценкой, то есть цена для вашего покупателя. Пагинация идёт по has_more, а не по общему числу: считать точное количество на каждый запрос — лишняя нагрузка, а листать вперёд это не мешает. include=offers вкладывает номиналы прямо сюда — это ровно те же строки и те же цены, что отдаёт /catalog/{id}, читаются одним запросом на всю страницу вместо запроса на каждый товар. Витрине на 50 товаров это экономит 50 обращений. Учтите, что ответ становится заметно больше: если нужен только список названий и вилка цен, не просите.

GET/catalog/{id}

Товар и номиналы

Конкретные суммы пополнения или варианты ключа с ценой каждого. Товар, выключенный у вас в проекте, вернёт 404 — со стороны магазина его просто нет в каталоге.

Запрос
curl https://gamesend.shop/api/v1/catalog/1187 \
  -H "Authorization: Bearer gs_live_ваш_ключ"
Ответ
{
  "id": 1187,
  "name": "PUBG Mobile",
  "category": "topups",
  "category_label": "Пополнение игр",
  "region": null,
  "offers": [
    { "id": 88104, "name": "60 UC",  "cost": 0.89,
      "stock": null, "min_qty": null, "max_qty": null },
    { "id": 88105, "name": "325 UC", "cost": 4.41,
      "stock": 42,   "min_qty": 1,    "max_qty": 10 }
  ],
  "fields": [
    { "key": "user_id",   "label": "User ID", "type": "text" },
    { "key": "server_id", "label": "Server",  "type": "select",
      "options": [ { "value": "europe", "label": "Europe" },
                   { "value": "asia",   "label": "Asia"   } ] }
  ]
}

Подробности

cost — сколько вы платите нам. Розничную цену вы ставите у себя: поля price_* и price из каталога убраны 15.09.2026 вместе с настройкой наценки в кабинете — она всё равно ни на что у нас не влияла, а два места для одного числа расходились. stock: null означает, что поставщик не считает остаток по этой позиции — это не ноль. min_qty и max_qty — пределы поставщика на quantity; null значит «без ограничения», выход за них отвергается до списания денег. У пополнений (topups) quantity всегда равен 1 независимо от этих полей: количество задаётся выбором номинала. fields — что спросить у покупателя перед заказом. Все перечисленные обязательны. Ключи задаёт поставщик и они различаются от игры к игре — у 8 Ball Pool это user_id, у Age of Empire Mobile player_id плюс server_id, у AFK Journey account_id, — поэтому читайте их отсюда, а не зашивайте. Именно эти ключи уходят в объект fields при покупке; пустой список означает, что спрашивать нечего. У каждого поля есть type: "text" — свободный ввод, "select" — закрытый список, и тогда рядом лежит непустой options, а значение обязано быть ровно одним из options[].value (сервер поставщика отвергнет любое другое уже после списания). Поля type и options добавлены 15.09.2026; старые интеграции их просто не читают, ключи и label не менялись.

GET/balance

Баланс

Остаток на счёте аккаунта и последние движения по нему. Счёт один на все ваши проекты.

Запрос
curl https://gamesend.shop/api/v1/balance \
  -H "Authorization: Bearer gs_live_ваш_ключ"
Ответ
{
  "balance": 24.50,
  "overdraft": 0,
  "available": 24.50,
  "currency": "USD",
  "recent": [
    { "kind": "charge", "amount": -0.82, "balance_after": 24.50,
      "order_id": "ord_XXXXXXXX",
      "comment": "telegram_stars для @ivan", "at": "2026-09-05 12:04:11" },
    { "kind": "topup", "amount": 25.00, "balance_after": 25.32,
      "order_id": null,
      "comment": "Пополнение 25.0000 USDT", "at": "2026-09-05 11:58:02" }
  ]
}

Подробности

available — то, что реально можно потратить прямо сейчас: сравнивайте цену с ним, а не с balance. Обычно это одно и то же число, но если мы открыли вам овердрафт, overdraft показывает разрешённый минус, а balance после его использования становится отрицательным — это не ошибка ответа. Овердрафт по умолчанию нулевой, включается только по договорённости. order_id — заказ, за который списали: по нему можно найти потерянный заказ, если вебхук не дошёл. У пополнений и корректировок он null. Если у вас несколько проектов, движения приходят по всем: счёт общий. Заказ по чужому ключу при этом остаётся чужим — GET /orders/{id} отвечает только по заказам того проекта, чьим ключом вы спрашиваете.

GET/products

Что принимают свои товары

Границы сумм, доступные валюты и обязательные поля — те же самые, по которым заказ проверяется при создании. Нужен, чтобы форма не давала ввести то, что мы потом откажемся принять.

Запрос
curl https://gamesend.shop/api/v1/products \
  -H "Authorization: Bearer gs_live_ваш_ключ"
Ответ
{
  "products": [
    { "product": "telegram_stars", "required": ["username", "quantity"],
      "quantity": { "min": 50, "max": null, "step": 1, "fractional": false } },

    { "product": "telegram_premium", "required": ["username", "months"],
      "variants": { "months": [3, 6, 12] } },

    { "product": "steam_topup", "available": true,
      "required": ["steam_login", "amount", "currency"],
      "currencies": [
        { "code": "USD", "rate": 1,        "decimals": 2, "min": "0.15",  "max": "1000.00" },
        { "code": "RUB", "rate": 84.2377,  "decimals": 4, "min": "12.6357",
          "max": "84237.7050" }
      ],
      "nominal_usd": { "min": 0.15, "max": 1000 },
      "limit_scope": "order",
      "rates_updated_at": "2026-09-13 14:20:03" }
  ],
  "usd_rub": 87.61
}

Подробности

usd_rub — курс для вашей рублёвой витрины, тот же, что отдаёт GET /rates. Он НЕ равен currencies[].rate у steam_topup и не должен им подменяться: тот курс — делитель, которым сумма в рублях превращается в номинал, и счёт считается по нему. Границы Steam заданы в долларах номинала и пересчитаны по курсу поставщика на момент ответа. min округлён вверх, max вниз, так что значение, взятое отсюда, гарантированно пройдёт проверку. Кэшируйте на минуту — в ответе это поле cache_seconds, и то же самое приходит заголовком Cache-Control: private, max-age=60. Чаще незачем: курсы у поставщика мы перечитываем раз в две минуты (rates_refresh_seconds), так что минутный кэш не сделает вас устаревшее нас. Лимит метода — 120 запросов в минуту на проект, отдельный от лимита /quote. limit_scope: order — тысяча долларов это предел на один заказ; ограничений на сутки или на проект нет, потолком служит баланс. available: false у steam_topup означает, что поставщик или его курсы сейчас недоступны — заказы по нему не примутся.

GET/rates

Курс для рублёвой витрины

Сколько рублей стоит доллар, если считать по нему розницу. Наши цены, балансы и пополнения — в долларах; этот курс нужен, чтобы перевести их в рубли у себя.

Запрос
curl https://gamesend.shop/api/v1/rates   -H "Authorization: Bearer gs_live_ваш_ключ"
Ответ
{
  "base": "USD",
  "rates": { "RUB": 87.61 },
  "updated_at": "2026-09-16 15:21:19",
  "cache_seconds": 60
}

Подробности

Это НЕ тот курс, что лежит в /products → steam_topup → currencies[].rate. Тот — делитель, которым сумма в рублях превращается в долларовый номинал, и счёт за пополнение Steam считается именно по нему: возьмёте для номинала этот курс — и посчитанная вами сумма разойдётся с нашей, а значения у краёв диапазона будут отклонены. Здесь — курс для витрины: по нему считайте свою розницу, он учитывает стоимость конвертации рублей в доллары. Кэшируйте на минуту (cache_seconds, тот же Cache-Control: private, max-age=60), лимит общий с чтением — 120 запросов в минуту. То же число дублируется в /products полем usd_rub, чтобы не делать второй запрос, если вы и так туда ходите.

POST/recipient

Кто получит подарок

Имя и аватар владельца username в Telegram — то же, что показывает форма Fragment. Плюс ответ на вопрос, можно ли этому человеку подарить выбранное.

Параметры

product
telegram_stars или telegram_premium
username
Получатель, без @
months
Для Premium: 3, 6 или 12
quantity
Для звёзд: сколько. На то, кто получатель, не влияет
Запрос
curl -X POST https://gamesend.shop/api/v1/recipient \
  -H "Authorization: Bearer gs_live_ваш_ключ" \
  -H "Content-Type: application/json" \
  -d '{ "product": "telegram_premium", "username": "ivan", "months": 3 }'
Ответ
{
  "found": true,
  "can_receive": false,
  "name": "Sergey Drozdov",
  "photo": "https://cdn4.telesco.pe/file/…jpg",
  "reason": "has_premium",
  "resolution": "fragment_ok"
}

Подробности

Три поля, три разных вопроса. found — нашли ли человека; can_receive — можно ли выдать ему именно это; reason — что это значит для получателя; resolution — что ответил источник в этой попытке. Решение о продаже принимайте по can_receive, карточку рисуйте по found, текст покупателю пишите по reason, а resolution берите только в логи. reason: found; has_premium — Premium у него уже есть; not_resolvable — за этим ником никого не удалось получить; unavailable — отказ по другой причине; unknown — источник не ответил, found и can_receive придут null, заказ создавать можно. Мы НЕ отдаём «такого пользователя нет» и не будем: снаружи отсутствующий аккаунт и аккаунт, который Fragment не отдаёт, — это один и тот же ответ, и @durov тому пример. resolution: fragment_ok — ник разрешён; fragment_said_no — Fragment заявил, что такого ника нет (иногда ошибочно); fragment_declined — отказал без объяснения; fragment_silent — не ответил. Для steam_topup — supplier_ok / supplier_said_no / supplier_silent. Для Steam передавайте product: steam_topup и steam_login вместо username; reason там cannot_refill, если Steam не примет пополнение. Показывайте имя и аватар покупателю до оплаты: это снимает большую часть ошибок в username. photo — прямая ссылка на CDN Telegram, она может смениться, не кэшируйте её надолго. Ответ кэшируется у нас на минуту. Лимит общий с /quote — 60 запросов в минуту.

POST/quote

Цена без покупки

То же тело, что у покупки, но ничего не создаётся и не списывается. Нужен, чтобы посчитать свою цену покупателю до заказа: у звёзд, Premium и пополнения Steam цена берётся из живых курсов, и в каталоге её нет.

Параметры

offer
Товар каталога: id номинала из GET /catalog/{id}
product
Свои товары: telegram_stars, telegram_premium или steam_topup
quantity
Для звёзд и каталога — количество
months
Для Premium: 3, 6 или 12
username
Для Premium: получатель. Без него цена ориентировочная — см. estimate
steam_login
Для Steam не нужен: цена от суммы, а не от аккаунта
amount
Для Steam: сумма зачисления
currency
Для Steam: валюта суммы
Запрос
curl -X POST https://gamesend.shop/api/v1/quote \
  -H "Authorization: Bearer gs_live_ваш_ключ" \
  -H "Content-Type: application/json" \
  -d '{
    "product": "telegram_premium",
    "months": 3,
    "username": "ivan"
  }'
Ответ
{
  "price": 13.19,
  "currency": "USD",
  "kind": "telegram_premium",
  "estimate": false,
  "balance": 24.50,
  "enough": true,
  "recipient": {
    "name": "Sergey Drozdov",
    "photo": "https://cdn4.telesco.pe/file/…jpg"
  }
}

Подробности

estimate: true бывает только у Premium: цену за конкретного получателя называет Fragment, и когда её получить не удалось — имя не передали или Fragment не ответил — в ответе стоит наша сохранённая база, то есть ориентир, а не обещание. Передавайте username: тогда при estimate: false цена ровно та, что спишется при заказе, в ответе появляется recipient с именем и аватаром получателя, а если подарить нельзя — придёт 422 recipient_refused с причиной и с тем же recipient внутри объекта error, ещё до того как вы возьмёте деньги с покупателя. Отдельный запрос к POST /recipient при этом не нужен: у Premium имя и аватар приходят вместе с ценой, у звёзд — одним кэшируемым запросом. Лимит у метода свой: 60 запросов в минуту, отдельно от лимита заказов.

POST/orders

Покупка

Создаёт заказ и сразу списывает деньги с баланса. Выдача идёт в фоне — статус смотрите следующим методом.

Параметры

offer
Товар каталога: id номинала из GET /catalog/{id}
fields
Для пополнений: данные покупателя из поля fields того же ответа
product
Свои товары: telegram_stars, telegram_premium или steam_topup
username
Для Telegram: получатель, без @
quantity
Для звёзд — сколько штук, минимум 50; для каталога — сколько единиц
months
Для Premium: 3, 6 или 12
steam_login
Для Steam: логин аккаунта, латиницей
amount
Для Steam: сумма зачисления на аккаунт
currency
Для Steam: валюта суммы, например RUB или KZT
client_ref
Ваш номер заказа, обязателен. Повтор с тем же значением вернёт прежний заказ, а не купит второй
Запрос
curl -X POST https://gamesend.shop/api/v1/orders \
  -H "Authorization: Bearer gs_live_ваш_ключ" \
  -H "Content-Type: application/json" \
  -d '{
    "product": "telegram_stars",
    "username": "ivan",
    "quantity": 50,
    "client_ref": "shop-order-1024"
  }'
Ответ
{
  "order": {
    "id": "ord_XXXXXXXX",
    "status": "processing",
    "product": "telegram_stars",
    "username": "ivan",
    "quantity": 50,
    "months": null,
    "price": 0.82,
    "client_ref": "shop-order-1024",
    "created_at": "2026-09-05 12:04:11",
    "delivered_at": null,
    "error": null
  },
  "balance": 24.50
}

Подробности

Обязательно передавайте client_ref. Если запрос отвалится по таймауту, повтор с тем же значением вернёт тот же заказ и не купит второй раз — ответ будет 200 вместо 201.

POST/orders

Пополнение Steam

Тот же метод, но сумму называете вы: amount — сколько зачислить на аккаунт, currency — в какой валюте. Цена считается по курсу на момент заказа.

Запрос
curl -X POST https://gamesend.shop/api/v1/orders \
  -H "Authorization: Bearer gs_live_ваш_ключ" \
  -H "Content-Type: application/json" \
  -d '{
    "product": "steam_topup",
    "steam_login": "ivan_2004",
    "amount": "1000",
    "currency": "RUB",
    "client_ref": "shop-order-1025"
  }'
Ответ
{
  "order": {
    "id": "ord_XXXXXXXX",
    "status": "processing",
    "product": "steam_topup",
    "price": 12.34,
    "client_ref": "shop-order-1025",
    "created_at": "2026-09-10 12:04:11",
    "delivered_at": null,
    "error": null
  },
  "balance": 24.50
}

Подробности

Логин, а не отображаемое имя: деньги уходят на тот аккаунт, который назвали, и вернуть их нельзя. client_ref обязателен.

GET/orders

Список заказов

Ваши заказы, новые сверху. Нужен, чтобы сверить день, чтобы найти заказ, id которого вы не сохранили, — и чтобы следить за выдачей, если вебхук вам не подходит.

Параметры

status
processing, delivered или failed
client_ref
Ваш номер заказа. Точное совпадение — вернётся не больше одного
created_from
С какой даты: YYYY-MM-DD или YYYY-MM-DD HH:MM:SS, UTC
created_to
По какую. Дата без времени включает весь день
updated_since
Что изменилось с этого момента, старые изменения первыми. Так следят за выдачей без вебхука
page
Страница, с 1
per_page
Сколько на странице: по умолчанию 50, максимум 100
Запрос
curl "https://gamesend.shop/api/v1/orders?status=failed&created_from=2026-09-12" \
  -H "Authorization: Bearer gs_live_ваш_ключ"
Ответ
{
  "orders": [
    {
      "id": "ord_XXXXXXXX",
      "status": "delivered",
      "product": "gift_cards",
      "username": null,
      "quantity": 1,
      "months": null,
      "updated_at": "2026-09-15 08:31:04",
      "price": 0.52,
      "client_ref": "shop-order-1024",
      "created_at": "2026-09-12 12:04:11",
      "delivered_at": "2026-09-12 12:04:47",
      "error": null,
      "item": { "product_id": 395, "product_name": "Roblox (Global)",
                "offer_id": 1025, "offer_name": "800 Robux" }
    }
  ],
  "page": 1,
  "per_page": 50,
  "has_more": true
}

Подробности

Коды выданных товаров в списке НЕ приходят — как и данные покупателя из fields. Нашли нужный заказ, забрали коды через GET /orders/{id}: так один утёкший ключ не выгружает всю историю кодов одним запросом. has_more — это не общее число: при активной торговле страницы съезжают, поэтому для сверки надёжнее сужать created_from и created_to, чем листать вглубь.

GET/orders/{id}

Статус заказа

Три состояния: processing — в работе, delivered — выдано, failed — не получилось, деньги вернулись на баланс. Здесь же приезжает сам товар: у ключей и подарочных карт коды лежат в delivery.items.

Запрос
curl https://gamesend.shop/api/v1/orders/ord_XXXXXXXX \
  -H "Authorization: Bearer gs_live_ваш_ключ"
Ответ
{
  "order": {
    "id": "ord_XXXXXXXX",
    "status": "delivered",
    "price": 0.82,
    "delivered_at": "2026-09-05 12:04:47",
    "error": null,

    "item": {
      "product_id": 395,
      "product_name": "Roblox (Global)",
      "offer_id": 1025,
      "offer_name": "800 Robux"
    },
    "fields": null,
    "delivery": {
      "items": [
        { "code": "P2DV-5JALB3-J8BB" }
      ]
    }
  }
}

Подробности

delivery появляется только у товаров каталога и только когда status = delivered: до выдачи там null, а не пустой список. В items столько кодов, сколько прислал поставщик — обычно по одному на единицу quantity, но сверяйте с длиной массива, а не считайте по quantity. У пополнений (topups) кодов нет вовсе, там items пустой: товар ушёл прямо на игровой аккаунт из fields. Для звёзд, Premium и Steam полей item, fields и delivery нет — это не каталог.

Песочница

Второй ключ, gs_test_…, к тому же проекту. Тот же каталог, те же цены, те же коды ошибок — но игровые деньги, и ни одного обращения к поставщику или к Fragment.

  • Режим решает ключ, а не запрос.

    Поле в теле, которое отменяет списание, — слишком дешёвая вещь, чтобы на ней держалась граница. Какой ключ прислали, такой и режим; подделать нельзя.

  • Миры не пересекаются.

    Тестовый ключ не видит боевых заказов и не может ими управлять, боевой не видит тестовых. Один и тот же client_ref можно прогнать сначала в песочнице, потом в бою.

  • Свой адрес вебхука и свой секрет.

    Задаются отдельно. Если указать один адрес на оба режима, боевой приёмник не сойдётся по подписи — так и задумано.

  • Заказ не мгновенный.

    Обычный тестовый заказ идёт около десяти секунд и проходит те же промежуточные статусы, что боевой. Мгновенная выдача научила бы вас коду, который никогда не видел processing.

Сценарии — поле test_scenario при создании заказа

okпо умолчанию: выдача примерно через 10 секунд
fastвыдача на ближайшем тике, 1–5 секунд — для CI
slowвыдача через 3 минуты: проверить, что долгий processing не считается ошибкой
supplier_refusedпровал с кодом SUPPLIER_ERROR, игровые деньги возвращены
invalid_fieldsпровал: «Данные покупателя не прошли проверку»
recipient_unavailableпровал с кодом RECIPIENT_NOT_FOUND
out_of_stockсинхронный 409 в ответе на POST — заказ вообще не создаётся
stuckвисит в работе; через час закрывается по таймауту с возвратом

Зарезервированные получатели

В песочнице /recipient отвечает по таблице и никуда не ходит.

gs_sandbox_okнайден и можно выдать
gs_sandbox_missingне разрешается
gs_sandbox_haspremiumнайден, но Premium уже есть
gs_sandbox_nogiftsнайден, выдать нельзя
gs_sandbox_silentисточник не ответил
gs_sandbox_badдля Steam

Любой другой корректный ник считается найденным.

Управление: GET и POST /sandbox

GET/sandboxбаланс, список сценариев и ников
POST/sandbox {"action":"topup","amount":100}долить игровых денег
POST/sandbox {"action":"balance","amount":3.00}выставить точный остаток
POST/sandbox {"action":"advance","order":"ord_test_…"}протолкнуть заказ сейчас

Боевым ключом эти методы отвечают 403 sandbox_only. Чтобы воспроизвести 402 insufficient_funds в CI с первой попытки: выставьте остаток меньше цены и создайте обычный заказ — придёт та же ошибка с теми же полями required и balance, что в бою.

Подробности

Выданные коды каталога выглядят как GS-TEST-XXXX-01 — так их видно на экране, если магазин печатает выдачу покупателю. Звёзды, Premium и Steam выдачи не возвращают ни в песочнице, ни в бою.

Как узнать о выдаче

Опросом. Вебхуков мы не шлём — и это решение, а не задел на будущее.

Заказ создаётся мгновенно, а выдача идёт в фоне — обычно секунды. Чем кончилось, узнаёте сами, и способа два.

Один заказ

GET /orders/{id} — коды приходят там же, как только появятся. Годится, пока заказов немного.

Всё, что изменилось

GET /orders?updated_since=… — что поменялось с этого момента, старые изменения первыми. Это и есть замена вебхуку.

Как следить
// Раз в несколько секунд, начиная с того, что уже видели.
let since = "2026-09-15 08:30:00";

const res = await fetch(
  BASE + "/orders?updated_since=" + encodeURIComponent(since) + "&per_page=100",
  { headers: { Authorization: "Bearer gs_live_ваш_ключ" } },
);
const { orders } = await res.json();

for (const order of orders) {
  // Обрабатывайте по client_ref — он ваш и переживает что угодно.
  handle(order);
  // Курсор двигаем по ПОСЛЕДНЕМУ обработанному: ответ отсортирован по
  // updated_at по возрастанию, поэтому пропустить ничего нельзя.
  since = order.updated_at;
}

Подробности

Курсор берите из поля updated_at последнего обработанного заказа, а не из своих часов: наше время и ваше не совпадают, и на расхождении в секунду теряется ровно то изменение, ради которого вы опрашиваете. Сдвигайте его только после обработки — тогда обрыв посреди страницы стоит повтора, а не пропажи.

Подробности

Одно и то же изменение может прийти дважды: курсор по времени, а не по номеру. Делайте обработку идемпотентной по client_ref — тот же приём, что защищает вас от двойной покупки.

Ошибки

Формат один на все методы.

Формат
{ "error": { "code": "unauthorized", "message": "Ключ не найден или проект отключён" } }

У insufficient_funds рядом с message лежат числа — разбирать текст не нужно и не стоит: формулировки мы правим, поля нет. У остальных ошибок числа пока только в тексте; будем добавлять по мере надобности.

insufficient_funds
{
  "error": {
    "code": "insufficient_funds",
    "message": "Не хватает средств: нужно $12.24, на балансе $0.69",
    "required": 12.24,
    "balance": 0.69,
    "available": 0.69,
    "overdraft": 0,
    "currency": "USD"
  }
}

Коды

401unauthorizedКлюч не передан, неверен, или проект отключён
402insufficient_fundsНе хватает денег на балансе аккаунта
403project_disabledПроект отключён — заказы по нему не принимаются
404not_foundТовара или заказа нет — либо он выключен вами
400invalid_fieldsНе заполнено или слишком длинное поле из fields
409out_of_stockОстаток меньше, чем просите
422not_deliverableЭтот товар через API пока не заказать
422recipient_refusedПолучателю нельзя подарить Premium — причина в message
422term_unavailableЭтот срок Premium сейчас не продаётся
500pricing_errorЦена настроена неверно — напишите нам
503upstream_unavailableЦена у Fragment временно недоступна
503no_ratesКурс для пополнения Steam сейчас неизвестен
429rate_limitedСлишком часто — см. заголовок Retry-After
400bad_requestНекорректный параметр запроса

Деньги на счету

Пополнения по API не будет — это решение, а не очередь. Пополнение живёт в кабинете: USDT в сети TON или TRC20, GRAM, зачисление автоматическое по точной сумме. Планируйте интеграцию из того, что баланс пополняет человек.

Поэтому и о низком балансе мы пишем в бота, а не вебхуком: сообщение приходит туда, где этот человек уже есть — в @gamesend_bot, тот же, через который вы входите. Предупреждаем один раз на подходе к нулю и повторяем только после того, как баланс восстановился. О заказах узнаёте опросом — см. «Как узнать о выдаче».

Текущий остаток машинно — GET /balance.