API для партнёров
Базовый адрес https://persshop.com/api/v1. Всё общение — JSON. Суммы в сомони, две цифры после запятой.
Ключ
Ключ выдаётся после одобрения заявки и выглядит как ps_live_abc123.секрет. Он показывается один раз — храните его на сервере, а не в коде бота, который лежит в общем репозитории. Если ключ утёк, напишите в чат: старый отзовём, выдадим новый.
Authorization: Bearer ps_live_abc123.ваш_секретНе больше 120 запросов в минуту на ключ. Сверх лимита — 429 с полем retryAfterSeconds.
Ошибки
У любой ошибки есть машинный code и человеческий error. Разбирайте первый: тексты мы можем переписать, коды — нет.
{ "ok": false, "code": "insufficient_balance", "error": "Недостаточно средств на балансе." }unauthorized— ключ не подходит ·key_revoked— ключ отозванrate_limited— слишком часто ·not_found— нет такой категории или заказаmissing_field— не заполнено поле заказа (какое — в полеfield)insufficient_balance— не хватает денег ·fulfillment_failed— поставщик не выдал, деньги уже вернулись на балансcheck_unavailable— у этой игры нет проверки ID ·check_unavailable_now— проверка сломалась у поставщика, номер тут ни при чёмidempotency_key_reuse,in_progress— см. ниже
Ручки
GET /api/v1/catalog
q — поиск по названию, limit, offset. Цен здесь нет — они зависят от позиции, а не от категории. Картинки — полными адресами: imageUrl — обложка, iconUrl — квадратная иконка игры (бывает null).GET /api/v1/catalog/{id}
priceTjs — ваша, retailPriceTjs — наша розница. idCheckField непустое — значит, у этой игры можно спросить ник до оплаты (см. /check-id). У каждой позиции —imageUrl, картинка номинала как на нашей витрине (где своей нет — иконка или обложка игры).{
"ok": true,
"category": {
"id": "free-fire",
"kind": "topup",
"title": "Free Fire",
"imageUrl": "https://persshop.com/covers/free-fire.webp",
"iconUrl": "https://persshop.com/game-icons/free-fire.webp",
"fields": [{ "key": "player_id", "label": "ID игрока", "type": "text" }],
"idCheckField": "player_id",
"offers": [
{ "offerId": "110_diamonds", "title": "110 Diamonds",
"priceTjs": 8.75, "retailPriceTjs": 9.95,
"imageUrl": "https://persshop.com/items/ff/diamonds-1.webp" }
],
"steamTopup": null
}
}GET /api/v1/balance
POST /api/v1/orders
POST /api/v1/orders
Authorization: Bearer ps_live_abc123.секрет
Idempotency-Key: 7f0c1e2a-2c1b-4a55-9f10-3e4d5c6b7a80
Content-Type: application/json
{
"categoryId": "free-fire",
"offerId": "110_diamonds",
"fields": { "player_id": "123456789" }
}Для пополнения Steam вместо offerId шлите сумму:{ "categoryId": "steam-topup", "amountUsd": 20, "fields": { "login": "steamlogin" } }В ответ — заказ со статусом. Для подарочных карт и ключей код лежит в delivery.{
"ok": true,
"order": {
"id": "0f2c…", "number": 128,
"categoryId": "free-fire", "offerId": "110_diamonds",
"priceTjs": 8.75, "status": "fulfilled",
"delivery": null, "error": null
}
}GET /api/v1/orders/{id}
GET /api/v1/orders
status (fulfilling, fulfilled, refunded, failed), limit (до 100), offset. В ответе total — сколько их всего, по нему видно, всё ли выкачано.{ "ok": true, "total": 412, "orders": [ { "id": "0f2c…", "number": 128, … } ] }POST /api/v1/check-id
/catalog/{id} заполнено поле idCheckField.POST /api/v1/check-id
{ "categoryId": "free-fire", "fields": { "player_id": "123456789" } }
{ "ok": true, "valid": true, "playerName": "PlayerOne", "region": "SG" }valid: false — такого игрока нет, пусть клиент проверит номер. 503 check_unavailable_now — сломалась проверка на стороне поставщика; это не значит, что ID неверный, и заказ в этом случае оформлять можно. 400 check_unavailable — эта игра проверку не поддерживает вовсе.Вебхуки
Поставщик редко выдаёт мгновенно: ответ на заказ часто приходит со статусом fulfilling, а «выдано» случается через минуту, а иногда через полчаса. Чтобы не опрашивать нас по кругу, дайте адрес — мы постучимся сами, когда заказ закончится.
PUT /api/v1/webhook
{ "url": "https://ваш-сервер/persshop-hook" }
{ "ok": true, "webhook": { "url": "https://ваш-сервер/persshop-hook",
"secret": "whsec_…", "lastAttemptAt": null, "lastResult": null, "failsInARow": 0 } }GET /api/v1/webhook— что настроено сейчас и как прошла последняя доставка.PUTс{"url": null}— отключить. Секрет при этом остаётся.POSTс{"action":"rotate_secret"}— выдать новый секрет, если старый утёк.
Адрес обязан быть https и внешним. Приходит тот же объект заказа, что и в ответе на /orders:
POST на ваш адрес
X-PersShop-Signature: t=1788440698,v1=1a914c25…
{ "event": "order.fulfilled",
"sentAt": "2026-09-03T10:00:00.000Z",
"order": { "id": "0f2c…", "number": 128, "status": "fulfilled", "delivery": … } }Проверьте подпись. Возьмите t и v1 из заголовка, посчитайте HMAC-SHA256 от строки <t>.<тело запроса как есть> вашим secret и сравните с v1. Отбрасывайте всё, где t старше пяти минут, — иначе перехваченное сообщение можно прислать вам повторно.
// Node.js
const [t, v1] = header.split(",").map((p) => p.split("=")[1]);
const mine = crypto.createHmac("sha256", secret).update(t + "." + raw).digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(v1, "hex"), Buffer.from(mine, "hex"));- Отвечайте
200как можно быстрее. Мы ждём ответ 8 секунд и повторяем до трёх раз; на4xxне повторяем вовсе — считаем, что адрес неверный. - Одно и то же сообщение может прийти дважды, а порядок не гарантирован. Смотрите на
order.idиstatus, а не на факт прихода. - Вебхук не отменяет опрос. Ваш сервер бывает недоступен, наши попытки кончаются — раз в несколько минут сверяйтесь через
GET /api/v1/orders?status=fulfilling. С вебхуком этот опрос становится редким, а не ненужным.
Про Idempotency-Key
Рано или поздно ваш запрос оборвётся по таймауту уже после того, как мы списали деньги и заказали у поставщика. Ключ идемпотентности — то, что делает повтор безопасным.
- Повтор с тем же ключом и тем же телом вернёт тот же самый ответ, а не второй заказ.
- Пока первый запрос ещё выполняется, повтор получит
409 in_progress— подождите несколько секунд и спросите статус. - Тот же ключ с другим телом — это ошибка на вашей стороне, вернётся
422 idempotency_key_reuse. Берите новый ключ на каждый заказ, проще всего UUID. - Если запрос не дошёл до списания (не хватило баланса, не заполнено поле), ключ освобождается — можно исправить и повторить с ним же.
Что важно знать до интеграции
- Работаем только с предоплаты. Заказ списывается с баланса в момент оформления. Кончились деньги —
402 insufficient_balance, заказ не создаётся. - Сбой поставщика возвращает деньги сам. Если поставщик не выдал товар, заказ получает статус
refunded, а сумма возвращается на баланс — вручную ничего запрашивать не нужно. - ID покупателя проверяйте до оплаты. Пополнение уходит на тот аккаунт, который вы прислали. Ошиблись цифрой — вернуть нельзя, это уже деньги на чужом аккаунте.
- Цены меняются. Курс и наценка правятся, поэтому берите цену из
/catalog/{id}перед каждым заказом, а не из своей таблицы, заполненной однажды.