Paysell

Принимайте крипто-платежи

Paysell проводит расчёты в TON и USDT в сети TON. Вы создаёте счёт, мы выдаём ссылку, а вы получаете подписанный колбэк, когда деньги подтверждены в блокчейне и зачислены на баланс.

Обзор#

Что делает Paysell, а что — нет.

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

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

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

Как проходит платёж#

Шесть шагов, большинство из которых — наши.

Шесть шагов, большинство из которых — наши:

  1. 1

    Клиент нажимает «Оплатить»

    Ваш сервер вызывает наш API с суммой и своим номером заказа.

  2. 2

    Мы выдаём адрес

    Свежий адрес для приёма берётся из заранее подготовленного пула и привязывается к этому счёту. Один адрес принадлежит ровно одному открытому счёту — так платёж сопоставляется с ним.

  3. 3

    Клиент отправляет монеты

    Он сканирует QR-код или копирует адрес. Отправьте его на payment_url, который мы возвращаем, — страница уже всё показывает: сумму, адрес, QR-код, таймер, статус в реальном времени.

  4. 4

    Мы замечаем перевод

    Опрашиваются два независимых источника данных блокчейна, и их ответы сравниваются. Если они расходятся, мы останавливаемся, а не выбираем более удобный ответ.

  5. 5

    Мы ждём финальности

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

  6. 6

    Зачислено, и вам сообщили

    Комиссия вычитается, остаток попадает на ваш баланс, а на ваш сервер уходит подписанный вебхук с вашим order_id.

От оплаты до колбэка — около минуты: примерно пятнадцать секунд на подтверждения сети, остальное — наш проход по отслеживаемым адресам.

Куда идут деньги#

Комиссия и от чего она считается.

Комиссия — 0,2%, фиксируется для вашего магазина в момент регистрации. Если стандартная ставка изменится позже, ваша не изменится — она записывается в каждый счёт как число, а не как ссылка на настройку.

Комиссия берётся с того, что реально пришло, а не с того, что было запрошено в счёте. Выставили счёт на 5 USDT, а пришло 20 — комиссия считается с 20. Недоплатили — считается с того, что пришло.

example
Invoice:   5.000000 USDT
Received: 20.000000 USDT   (the buyer sent more)
Fee 0.2%:  0.040000 USDT   (on 20, not on 5)
Credited: 19.960000 USDT

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

Забрать монеты с адреса приёма стоит газа сети, и платим его мы — этой части ваш баланс не касается. Вывод на собственный адрес — другое дело: у него своя комиссия, она удерживается из запрошенной суммы, а точные числа лежат в тарифах.

Быстрый старт#

Пять минут до первого счёта.

Пять шагов. Два — это клики в кабинете, один — единственный запрос с вашего сервера, а последние два происходят сами.

  1. 1

    Создайте магазин

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

  2. 2

    Выпустите API-ключ

    Ваш магазин → API-ключи → Новый ключ. Ключ и секрет вебхука показываются один раз и больше никогда. Храните их как пароль от базы данных и никогда не отправляйте в браузер.

  3. 3

    Создайте счёт

    Один запрос с вашего сервера — одна ссылка в ответ. Четыре примера ниже отправляют ровно одно и то же.

  4. 4

    Отправьте покупателя на payment_url

    Это и есть вся страница оплаты — сумма, адрес, QR-код, обратный отсчёт, живое состояние, — и собирать ничего не нужно. Что именно видит покупатель, показано в разделе Страница оплаты.

  5. 5

    Дождитесь вебхука

    Когда деньги подтвердятся в сети и будут зачислены, мы сделаем POST с подписанным событием payment.credited на ваш сервер. Проверьте подпись и отметьте заказ оплаченным — но только если data.status равен paid или overpaid. См. Вебхуки.

Тот же запрос, четыре способа

curl -X POST https://paysell.me/api/merchant/v1/invoices \
  -H "Authorization: Bearer sk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "asset": "USDT_TON",
    "amount": "5",
    "order_id": "order-1042",
    "idempotency_key": "order-1042"
  }'

Перенаправьте покупателя на payment_url из ответа. Всё готово — остальное придёт вебхуком.

Что делать дальше

Аутентификация#

Ваш API-ключ и как он используется.

Каждый запрос несёт ваш ключ в заголовке Authorization:

http
Authorization: Bearer sk_live_IH4SNdnYsv-yabTuPCVln4vOvvGoEyxA

Все выдаваемые здесь ключи начинаются с sk_live_. Префикс sk_test_ бывает только у сборки, направленной на тестовую сеть, а такой сборки мы не предлагаем — см. Тестирование. Мы храним необратимый хеш, а не сам ключ, поэтому никто, включая нас, не может показать его вам снова. Потеряли? Выпустите новый и отзовите старый.

Магазин определяется по ключу, поэтому ни один запрос не принимает id магазина. Ключ может действовать только в рамках своего магазина.

В адресе есть версия: /api/merchant/v1/…. Внутри версии мы только добавляем поля — ничего не переименовывается и не меняет смысл втихую. Изменение, которое сломало бы ваш код, получает новый префикс /v2, а /v1 продолжает работать заранее объявленный срок.

Этот ключ создаёт счета от вашего имени. Храните его на сервере. Всё, что попадает в браузерный JavaScript, публично, как бы хорошо оно ни было спрятано.

Все методы разом#

Четыре вызова, три из них с ключом.

Это весь мерчантский API. Балансов, выводов и истории в нём нет — они живут в кабинете, где на них смотрит человек.

ПутьМетодДоступЧто делает
/invoicesPOSTAPI-ключВыставить счёт и получить ссылку на оплату. Подробнее.
/invoices/{invoice_id}GETAPI-ключПрочитать текущее состояние счёта. Подробнее.
/invoices/{invoice_id}/cancelPOSTAPI-ключЗакрыть ещё открытый счёт и освободить его адрес. Подробнее.
/public/invoices/{invoice_id}GETнетТо, что читает готовая страница оплаты. Нужен, только если вы рисуете свою. Подробнее.

Все пути — относительно https://paysell.me/api/merchant/v1. Метода со списком счетов нет, метода возврата нет — см. Возвраты.

Создание счёта#

POST /api/merchant/v1/invoices

POST/api/merchant/v1/invoices

Тело запроса

ПолеТипОбязательноеОписание
assetstringдаTON или USDT_TON.
amountstringдаОбычные единицы монеты, строкой: "5" это 5 USDT. Знаков после запятой не больше, чем есть у монеты. См. Суммы.
order_idstringнетВаш собственный номер заказа, до 200 символов. Возвращается в каждом вебхуке — так вы сопоставляете платёж с заказом.
descriptionstringнетДо 1000 символов. Показывается покупателю на странице оплаты.
ttl_minutesnumberнетСколько минут счёт остаётся доступным для оплаты. 1–1440; не указали — берётся значение по умолчанию, сейчас это 2 часа.
idempotency_keystringнетДо 200 символов. Отправьте то же значение при повторе запроса — получите тот же счёт, а не второй. Это поле тела, а не заголовок Idempotency-Key: заголовок здесь не читается.

Ответ · 201

json
{
  "invoice_id": "12c22c1a-a496-4c1e-abe3-72661ef8706e",
  "payment_url": "https://paysell.me/pay/12c22c1a-a496-4c1e-abe3-72661ef8706e",
  "address": "UQAvDJp7QDwqRcuNQBiK2GhBt71Xh1_UMYPCzMkQAoBPmZKl",
  "asset": "USDT_TON",
  "amount": "5",
  "amount_minor": "5000000",
  "status": "pending",
  "paid": "0",
  "paid_minor": "0",
  "order_id": "order-1042",
  "description": "Pro subscription",
  "expires_at": "2026-09-06T17:20:55Z",
  "created_at": "2026-09-06T15:20:55Z"
}

Как использовать поля в заказе

ПолеЧто с ним делать
invoice_idСохраните его для заказа. Именно по нему платёж определяется везде дальше.
payment_urlПеренаправьте покупателя сюда. Больше ничего собирать не нужно.
addressТолько если рисуете свою страницу оплаты. Показывайте его ровно как получили — см. предупреждение ниже.
amountСумма в обычных единицах, ровно как вы её прислали. Показывайте её.
amount_minorТа же сумма целым числом в минимальных единицах. Считайте по ней.
expires_atПоказывайте обратный отсчёт. После этого момента адрес перестаёт отслеживаться для этого счёта.
statusЗдесь всегда pending. Реальные изменения статуса приходят вебхуком.
Если рисуете свою страницу, выводите адрес ровно как он был получен. Он в небаунсабельной форме (UQ… в основной сети, 0Q… в тестовой). Преобразуете его, приукрасите или замените на другое представление того же адреса — и монеты, отправленные на ещё не развёрнутый кошелёк, вернутся отправителю.

Получение счёта#

GET /api/merchant/v1/invoices/{invoice_id}

GET/api/merchant/v1/invoices/{invoice_id}

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

Опрашивайте не чаще, чем раз в несколько секунд, и считайте вебхуки основным каналом. Счета, принадлежащие другому магазину, отвечают 404, а не 403 — так id нельзя прощупать на существование.

Отмена счёта#

POST /api/merchant/v1/invoices/{invoice_id}/cancel

POST/api/merchant/v1/invoices/{invoice_id}/cancel

Закрывает счёт, который ещё открыт — pending или underpaid, — и освобождает его адрес. Используйте, когда покупатель бросил оформление заказа: адреса это ограниченный ресурс, а их возврат поддерживает пул в порядке.

Счёт, который уже не открыт, отвечает 409. Отмена underpaid никому не возвращает монеты: зачисленные деньги остаются на вашем балансе, закрывается только приём доплаты.

Вебхуки#

Что приходит и как это проверять.

Адрес вебхука задаётся при создании ключа. Мы делаем на него POST, когда платёж зачислен, — и когда поступление, ушедшее на дополнительную проверку, отклонено. Каждая доставка подписана, попытки повторяются около полутора суток, пока не придёт 2xx. Отгружайте товар по status: paid или overpaid, а не по самому факту вызова.

События

СобытиеКогдаЧто в теле
payment.creditedПеревод подтверждён в сети, комиссия удержана, остальное на вашем балансе.Поля, перечисленные ниже.
payment.rejectedПоступление, отложенное на дополнительную проверку (см. Справочник статусов), отклонено. Деньги на баланс не попадут.invoice_id, order_id, asset, amount, tx_hash и reason. Товар не отгружайте; если счёт уже был paid от более раннего перевода, событие относится к лишнему поступлению, а не к той оплате.

Что приходит

json
{
  "event_id": "99f74f58-efbb-4af1-b0a3-76b0073f9e6b",
  "type": "payment.credited",
  "data": {
    "invoice_id": "12c22c1a-a496-4c1e-abe3-72661ef8706e",
    "order_id": "order-1042",
    "asset": "USDT_TON",
    "amount": "5000000",
    "credited": "4905000",
    "fee": "95000",
    "status": "paid",
    "paid_minor": "5000000",
    "tx_hash": "97a1f0…"
  }
}
json
{
  "event_id": "0a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d",
  "type": "payment.rejected",
  "data": {
    "invoice_id": "12c22c1a-a496-4c1e-abe3-72661ef8706e",
    "order_id": "order-1042",
    "asset": "USDT_TON",
    "amount": "5000000",
    "tx_hash": "97a1f0…",
    "reason": "could not be matched to any order"
  }
}

Соответствие полей

ПолеЗначение
event_idУникален для каждого события; он же приходит в заголовке X-Paysell-Event-Id. Сохраняйте его и игнорируйте повторы — см. ниже.
data.order_idВаш номер заказа. Ищите заказ по нему.
data.amountСколько прислал покупатель этим переводом, в минимальных единицах — в отличие от API, который принимает обычные.
data.feeСколько мы удержали, в минимальных единицах.
data.creditedСколько попало на баланс: amount − fee, в минимальных единицах.
data.paid_minorСколько всего получено по этому счёту, в минимальных единицах. Главное поле при underpaid: статус говорит «пришло меньше», а это число — насколько меньше.
data.assetМонета, которая реально пришла. Не обязательно та, в которой выставлен счёт.
data.asset_mismatchЕсть и равно true только тогда, когда пришедшая монета не совпадает с монетой счёта. Деньги вам зачислены, но счёт остаётся неоплаченным, и status никогда не будет paid.
data.invoice_assetПриходит вместе с asset_mismatch: монета, в которой выставлен счёт.
data.statusСостояние счёта на этот момент: pending, underpaid, paid, overpaid или expired. Сравните с тем, что ожидали.
data.tx_hashТранзакция в блокчейне — для ваших записей и поддержки.

Заголовки каждой доставки

http
X-Paysell-Event:      payment.credited
X-Paysell-Event-Id:   99f74f58-efbb-4af1-b0a3-76b0073f9e6b
X-Paysell-Timestamp:  1789000000
X-Paysell-Signature:  sha256=6f1c0e6a…
ЗаголовокЗначение
X-Paysell-EventТип события: payment.credited или payment.rejected.
X-Paysell-Event-IdУникален для события. Именно по нему делайте дедупликацию.
X-Paysell-TimestampМомент подписи, в unix-секундах. Входит в подписываемую строку.
X-Paysell-Signaturesha256= и следом HMAC в hex. Как проверять — ниже.

Проверка подписи

Каждый запрос подписывается секретом вебхука, который показывается один раз при создании ключа. Подпись — это HMAC-SHA256(secret, "{timestamp}.{raw_body}"): метка времени из X-Paysell-Timestamp, точка, затем байты тела. Проверяйте её перед тем, как действовать: иначе любой, кто узнает ваш URL, сможет подсунуть вам «оплаченный» заказ.

Python:

python
import hmac, hashlib, time

def is_ours(body: bytes, signature: str, timestamp: str, secret: str) -> bool:
    if abs(time.time() - int(timestamp)) > 300:      # ±5 minutes
        return False
    signed = timestamp.encode() + b"." + body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    # compare_digest, not ==: a plain comparison leaks the answer through timing
    return hmac.compare_digest("sha256=" + expected, signature)

Node.js:

javascript
const crypto = require("node:crypto")

function isOurs(body, signature, timestamp, secret) {
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false  // ±5 min
  const expected = "sha256=" + crypto
    .createHmac("sha256", secret)
    .update(timestamp + ".").update(body)   // body is the raw Buffer, not a parsed object
    .digest("hex")
  const a = Buffer.from(expected), b = Buffer.from(signature)
  // timingSafeEqual throws when the lengths differ, so check that first
  return a.length === b.length && crypto.timingSafeEqual(a, b)
}

Подписывайте исходные байты тела запроса, ровно как получили. Разберёте JSON и пересоберёте его — байты изменятся (порядок ключей, пробелы), и подпись перестанет совпадать. Сравнивайте за постоянное время (hmac.compare_digest, crypto.timingSafeEqual): обычное == возвращается быстрее на неверном первом байте, и этой разницы достаточно, чтобы подобрать подпись побайтно.

Окно метки времени

Отвергайте всё, у чего метка времени расходится с вашими часами больше чем на пять минут в любую сторону. Метка входит в подписываемую строку именно затем, чтобы её нельзя было подменить, не сломав подпись; окно превращает это в защиту. Без него один раз перехваченный запрос остаётся действительным навсегда и воспроизводится когда угодно — сама подпись не протухает. Держите часы сервера на NTP, иначе проверка начнёт отбраковывать нормальные доставки.

Повторы

Одно и то же событие может прийти больше одного раза. Это не баг: мы повторяем попытки, пока не получим 2xx, а доставка, которая прошла успешно, но чей ответ до нас не дошёл, отправляется снова. Запоминайте X-Paysell-Event-Id (он же event_id в теле) и делайте так, чтобы повторное поступление ничего не меняло.

Повторы

Первая попытка уходит сразу после зачисления платежа. Если она не удалась — таймаут, отказ в соединении, ошибка TLS, редирект или любой ответ не из 2xx, — дальше идёт фиксированное расписание:

1 мин → 5 мин → 15 мин → 1 ч → 6 ч → 24 ч

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

После последней попытки доставка помечается dropped, и мы прекращаем сами. Событие не теряется: в кабинете строка платежа показывает состояние, номер попытки и класс ошибки, а рядом кнопка «Отправить снова», которая запускает полный цикл из семи попыток заново. Второй путь — GET /api/merchant/v1/invoices/{invoice_id}: счёт всегда знает своё состояние сам.

Каким должен быть адрес вебхука

Адрес проверяется при сохранении и повторно перед каждой отправкой. Не прошедший проверку адрес при сохранении получает 422 с code: "webhook_url_rejected", а если перестал проходить позже — доставка помечается failed, и повторов не будет. Правила:

  • Только `https://` и порт 443. В вебхуке едут детали платежа; по обычному http их прочитает любой на пути.
  • Доменное имя, а не IP-адрес. Сертификат вам всё равно нужен, а на голый IP его не выдают.
  • Никаких `localhost`, .local, .internal, .corp, .lan и .test — до вашей внутренней сети наши серверы не дотянутся, а имя, которое разрешается внутри нашей, — ровно то, куда ходить нельзя.
  • Никаких логина и пароля в адресе (https://user:pass@…). Нужен свой токен — положите его в путь или в параметр запроса.
  • Все адреса, в которые разрешается имя, должны быть публичными — и A, и AAAA. Приватные, loopback, link-local и CGNAT-диапазоны отвергаются, а проверка повторяется перед каждой отправкой, так что перевести запись на 127.0.0.1 позже тоже не выйдет.
  • Редирект — это неудача, а не переход. Мы по ним не ходим: проверяли мы тот адрес, который дали вы, а тот, что в заголовке Location, никто не проверял.
Проверка стоит дважды намеренно: при сохранении — чтобы опечатка получила ответ сразу, а не молчаливой недоставкой через сутки; перед каждой отправкой — потому что владелец домена может в любой момент перевести запись на внутренний адрес. Если приёмник переезжает, сначала обновите ключ: отвергнутый адрес ничего не доставляет и в очередь не встаёт.

Отвечайте быстро

Подойдёт любой 2xx в течение десяти секунд — это весь наш таймаут, вместе с установкой соединения. Отвечайте сразу, медленную работу делайте после: приёмник, который ждёт свою базу до ответа, рано или поздно будет записан как таймаут и получит повтор, а вы обработаете одно событие дважды. Всё остальное — 4xx, 5xx, редирект, зависание — считается неудачной попыткой и уходит в расписание выше.

О сроках доставки — честно

Гарантирован механизм доставки: семь попыток примерно за 31 час, ручная переотправка из кабинета и эндпоинт счёта, который всегда знает реальный статус. Стройте поток так, чтобы непришедший вебхук ничего не стоил: читайте счёт на странице благодарности или раз в час сверяйте открытые счета. Вебхук — быстрый путь, но не единственный.

Готовый приёмник#

Подпись, дедупликация и быстрый ответ — целиком.

Примеры выше проверяют одну подпись. Здесь — весь обработчик: сырое тело, проверка подписи, отбрасывание повторов по event_id, быстрый 2xx и то единственное условие, по которому заказу можно ставить «оплачен».

Node.js и Express. Ошибаются обычно на express.raw: express.json() отдаёт разобранный объект, и байты, собранные из него обратно, — уже не те, которые мы подписывали.

javascript
const express = require("express")
const crypto = require("node:crypto")

const app = express()
const SECRET = process.env.PAYSELL_WEBHOOK_SECRET

function isOurs(body, signature, timestamp) {
  const sentAt = Number(timestamp)
  if (!Number.isFinite(sentAt)) return false
  if (Math.abs(Date.now() / 1000 - sentAt) > 300) return false   // ±5 minutes

  const expected = "sha256=" + crypto
    .createHmac("sha256", SECRET)
    .update(timestamp + ".").update(body)      // raw Buffer, not a parsed object
    .digest("hex")

  const a = Buffer.from(expected), b = Buffer.from(signature ?? "")
  return a.length === b.length && crypto.timingSafeEqual(a, b)
}

app.post(
  "/paysell/webhook",
  express.raw({ type: "application/json" }),   // NOT express.json()
  async (req, res) => {
    const signature = req.get("X-Paysell-Signature")
    const timestamp = req.get("X-Paysell-Timestamp")
    if (!isOurs(req.body, signature, timestamp)) return res.sendStatus(401)

    const event = JSON.parse(req.body.toString("utf8"))

    // Answer first: 10 seconds is the whole timeout, connection included.
    res.sendStatus(200)

    // Deduplicate. In real code this is a unique column, not a Set.
    if (await alreadyHandled(event.event_id)) return
    await remember(event.event_id)

    if (event.type !== "payment.credited") return
    const { order_id, status, credited, asset, tx_hash } = event.data

    // The only condition that may release the goods.
    if (status !== "paid" && status !== "overpaid") return
    await markOrderPaid(order_id, { credited, asset, tx_hash })
  }
)

Python и Flask. Сырое тело — это request.get_data(), а не request.form и не request.json.

python
import hashlib
import hmac
import json
import os
import time

from flask import Flask, request

app = Flask(__name__)
SECRET = os.environ["PAYSELL_WEBHOOK_SECRET"]


def is_ours(body: bytes, signature: str, timestamp: str) -> bool:
    try:
        sent_at = int(timestamp)
    except (TypeError, ValueError):
        return False
    if abs(time.time() - sent_at) > 300:              # ±5 minutes
        return False

    signed = timestamp.encode() + b"." + body
    expected = hmac.new(SECRET.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest("sha256=" + expected, signature)


@app.post("/paysell/webhook")
def paysell_webhook():
    body = request.get_data()                          # raw bytes, unparsed
    if not is_ours(body, request.headers.get("X-Paysell-Signature", ""),
                   request.headers.get("X-Paysell-Timestamp", "")):
        return "", 401

    event = json.loads(body)

    # Deduplicate. In real code this is a unique column, not a set.
    if already_handled(event["event_id"]):
        return "", 200                                 # a repeat is still a success
    remember(event["event_id"])

    if event["type"] == "payment.credited":
        data = event["data"]
        # The only condition that may release the goods.
        if data["status"] in ("paid", "overpaid"):
            mark_order_paid(data["order_id"], data)

    return "", 200                                     # 2xx within 10 seconds

Что здесь происходит и почему

  • Проверяйте подпись раньше, чем тело на что-то повлияет. Неподписанный запрос, дошедший до вашей бизнес-логики, — это оплаченный заказ для любого, кто узнал ваш адрес.
  • Сначала 2xx, работа потом. Десять секунд — это весь таймаут, вместе с установкой соединения. Обработчик, который ждёт собственную базу, запишется у нас как таймаут и получит повтор, а вы обработаете одно событие дважды.
  • Отбрасывайте повторы по `event_id`, и в хранилище, переживающем перезапуск. Множество в памяти в примерах — ради краткости; в настоящем коде это уникальный столбец в базе.
  • Отмечайте заказ оплаченным только при `status: paid` или `overpaid`. underpaid значит, что пришла часть денег и счёт ещё открыт, а поступление в чужой монете счёт не оплачивает вовсе.
  • На повтор тоже отвечайте 2xx. Повтор, получивший 4xx, выглядит отсюда как неудача и вернётся снова по расписанию.

payment.rejected приходит на тот же адрес. Оно означает, что поступление, ушедшее на дополнительную проверку, отклонено и денег не будет: ничего не отгружайте, а если счёт уже был оплачен более ранним переводом — это событие про лишнее поступление, а не про тот платёж.

Справочник статусов#

Все статусы счетов и платежей с пояснениями.

Счёт

СтатусЗначениеЧто делать
pendingЖдёт оплаты.Держите заказ открытым.
paidОплачен полностью.Отгружайте товар.
overpaidПришло больше, чем просили. Излишек зачисляется вам целиком.Отгружайте товар; разницу вернуть — по вашему усмотрению.
underpaidПришло меньше, чем просили. Счёт остаётся открытым и держит свой адрес: покупатель может доплатить туда же, а paid_minor говорит, сколько уже получено. Счёт принимает оплату весь оставшийся срок плюс льготные 24 часа после expires_at.Ждите доплату или договаривайтесь с покупателем. Товар не отгружайте — счёт не оплачен.
expiredСрок вышел, льготные сутки в том числе. Деньги при этом могут быть: всё, что пришло, осталось на вашем балансе, а paid_minor говорит сколько.Предложите новый счёт. Оплату на старый адрес не принимайте: после истечения адрес уходит обратно в пул, и очень поздний перевод — это разбор с поддержкой, а не автоматическое зачисление. Загляните в paid_minor прежде, чем сказать покупателю, что денег не поступало.
cancelledОтменён вами. Адрес возвращён в пул.Ничего.

Платёж

Виден в личном кабинете; полезен при поддержке клиента в процессе оплаты.

СтатусЗначение
detectedЗамечен в блокчейне, ожидает подтверждений.
confirmedСеть подтвердила. Далее — зачисление.
creditedНа балансе. В этот момент срабатывает вебхук.
reviewОтложен на дополнительную проверку — например, монеты пришли на адрес без открытого счёта.
rejectedНе зачислен. Причина зафиксирована.

Когда поступление уходит в `review`

Часть поступлений не зачисляется сразу, а уходит на дополнительную проверку: необычно крупная сумма, монеты, пришедшие на адрес без открытого счёта, или расхождение между двумя источниками данных о блокчейне, которые мы опрашиваем. Ничего не теряется — деньги ждут решения, и вебхук приходит сразу после него, а это могут быть минуты или часы. Поэтому отсутствие вызова по платежу в состоянии review — норма, а не сбой. Если это важно для заказа, напишите в поддержку и приложите tx_hash.

Суммы#

Наружу — обычные единицы, обратно — минимальные.

Суммы отправляются в обычных единицах монеты, строкой"1.5" это полтора. Не JSON-числом и не в минимальных единицах.

АктивЗнаков после запятойВы отправляетеamount_minor в ответе
TON9"1.5""1500000000"
USDT_TON6"1.5""1500000"

Строкой, а не числом, потому что JSON-число — это IEEE-754 double, и крупная сумма в наноTON перестаёт помещаться в него точно. Знаков после запятой больше, чем есть у монеты, — это 422, а не повод молча округлить ваши деньги. В вебхуках наоборот: amount, fee и credited там целые числа в минимальных единицах, потому что ту сторону читает код, а не человек.

javascript
// Send amounts in the coin's normal units, as a string:
const amount = "1.5"   // one and a half TON or USDT

// In responses, amount is that same human string; amount_minor is the
// integer in smallest units — use it for exact maths, as a string or BigInt:
BigInt(invoice.amount_minor)  // e.g. 1500000n

Лимиты#

Минимумы, максимумы и ограничения по частоте.

ЛимитЗначениеПри нарушении
Минимальный счёт0.1 TON · 3 USDT422
Максимальный счёт7000 TON · 10000 USDT422
Счетов в час на магазин60429
Одновременно открытых счетов20, растёт с каждым оплаченным, до 200429
Срок жизни счётаот 1 минуты до 24 часов (по умолчанию 2 часа)422
Запросов на ключ120 в минуту429 + Retry-After

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

Максимум существует не ради крупных магазинов, а как ловушка на ошибку в единицах. Пришлёте "5000000" там, где имели в виду "5", — и без него получился бы счёт на пять миллионов долларов: покупатель видит абсурдную сумму и уходит. Настоящий заказ в этот потолок не упирается никогда, ошибочный — всегда. Оба потолка задаются настройками (invoice_max_ton, invoice_max_usdt) и могут быть подняты для вашего магазина — напишите.

Часовой лимит и лимит одновременно открытых счетов защищают пул адресов. Каждый открытый счёт держит адрес приёма, и сорвавшийся цикл на одном сайте иначе вычерпал бы пул у всех сразу. Новый магазин держит открытыми 20 счетов; за каждый реально оплаченный лимит растёт на единицу, до потолка в 200. underpaid считается открытым — он всё ещё держит свой адрес и ждёт остаток. Отмена брошенного счёта возвращает адрес немедленно. Повторы с тем же idempotency_key в часовой лимит не идут.

Ограничение по запросам — 120 в минуту на ключ, то есть два вызова в секунду: заметно больше любого реального потока заказов. В ответе 429 приходит заголовок Retry-After с числом секунд: подождите столько, а не повторяйте в плотном цикле — это только отодвигает окно.

Ошибки#

Коды состояния, которые вы реально увидите.

Ошибки возвращаются JSON-ом, и форм у него две. Всё, что решаем мы или ядро процессинга, кладёт под detail пару {code, message}. А тело запроса, не прошедшее проверку, кладёт туда список ошибок по полям. Прежде чем читать detail.code, посмотрите, что именно вам пришло, — и решения принимайте по `code`, а не по `message`: формулировка может измениться в любой момент, код — нет.

json
{
  "detail": {
    "code": "invalid_input",
    "message": "invoice amount below the minimum: 0.010000 USDT_TON, minimum 3.000000 USDT_TON"
  }
}
json
{
  "detail": [
    {
      "type": "string_type",
      "loc": ["body", "amount"],
      "msg": "Input should be a valid string",
      "input": 5
    }
  ]
}
СтатусКогдаЧто делать
401Ключ отсутствует, неверен или отозван.Проверьте заголовок. Отозванный ключ выпустите заново.
404Такого счёта нет либо он принадлежит другому магазину.Проверьте идентификатор. Эти два случая отвечают одинаково намеренно — иначе по идентификатору можно было бы проверять существование.
409Текущее состояние счёта не допускает такого действия.Сначала прочитайте его статус.
422Запрос неверно составлен либо сумма вне границ счёта.В тексте ошибки названы и присланное значение, и граница.
429Слишком много счетов за час, слишком много открытых или слишком много запросов.Дождитесь Retry-After и повторите.
502Мы не смогли достучаться до ядра обработки.Повторите с тем же ключом идемпотентности.

Коды

У формы, которую выбираем мы, вид {"detail": {"code": …, "message": …}}. Вот коды, которые возвращает мерчантский API.

КодСтатусЗначение
invalid_api_key401Ключ отсутствует, испорчен, неизвестен или отозван. Все четыре случая отвечают одинаково, поэтому ключ нельзя нащупать перебором.
not_found404Объекта нет либо он принадлежит другому магазину.
invalid_input422Запрос не прошёл проверку в ядре — неверная сумма, лишние знаки после запятой, сумма за границами счёта.
conflict409Действие противоречит текущему состоянию — например, отмена счёта, который уже не открыт.
too_many_requests429Ограничение частоты: счетов в час, открытых счетов или запросов в минуту. Retry-After говорит, сколько ждать.
cbc_unreachable502Не удалось достучаться до ядра процессинга. Повторите с тем же idempotency_key.
webhook_url_rejected422Только при сохранении ключа: адрес вебхука не прошёл проверки выше. detail.reason называет правило — scheme, port, ip_literal, local_hostname, private_address, dns_error, credentials и так далее.

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

Страница оплаты: что видит покупатель#

Готовая страница — и когда стоит делать свою.

payment_url ведёт на https://paysell.me/pay/{invoice_id}. Одна страница, без регистрации и входа, сначала мобильная, и собирать вам ничего не нужно.

Что на странице

  • Название вашего магазина, сумма и монета — крупно, под ними описание из счёта.
  • Обратный отсчёт до expires_at — плюс льготные сутки, если счёт в состоянии underpaid.
  • Короткий номер счёта с кнопкой «скопировать», чтобы покупатель мог назвать его вашей поддержке.
  • Кнопки кошельков: Tonkeeper и MyTonWallet открываются с уже подставленными адресом и суммой. Другой раскрывает QR-код и адрес с кнопкой копирования.
  • Предупреждение, что слать можно только монету этого счёта и только в сети TON — всё остальное пропадёт.

Как страница реагирует

СчётЧто видит покупатель
pending«Ждём оплату», выбор кошелька и обратный отсчёт. Страница перечитывает счёт каждые пять секунд.
underpaid«Получено X из Y», точный остаток к доплате и тот же адрес. В ссылку для кошелька подставляется недостающая часть, а не исходная сумма, — иначе покупатель заплатил бы дважды.
paid · overpaid«Платёж получен» и кнопка обратно в ваш магазин, если у магазина есть адрес сайта.
expired«Срок оплаты истёк». Если деньги всё же пришли, названа сумма и предложено связаться с вами: молчание отправило бы покупателя искать свои монеты.
cancelled«Платёж отменён» и ссылка обратно в ваш магазин.

Если делаете свою

Вы получаете собственное оформление и берёте на себя всё перечисленное: точную строку адреса, правильную монету, обратный отсчёт вместе с льготным сроком, случай недоплаты и опрос состояния. GET /api/merchant/v1/public/invoices/{invoice_id} — то же самое чтение без авторизации, которым пользуется готовая страница; оно ограничено по адресу, поэтому опрашивайте не чаще чем раз в несколько секунд. Адрес печатайте ровно в том виде, в каком он пришёл.

Типичные ошибки интеграции#

Те несколько, на которые приходится большинство поломок.

Ничего экзотического. Каждая из них уже стоила кому-то рабочего дня.

  • Сумма отправлена числом

    {"amount": 5} — это 422. Нужна строка "5": JSON-числа это IEEE-754 double, и крупная сумма в наноTON перестаёт представляться в нём точно.

  • Сумма отправлена в минимальных единицах

    "5000000" вместо 5 USDT — ошибка в единицах, и верхняя граница счёта существует ровно для того, чтобы её поймать. Минимальные единицы — это то, что приходит обратно в вебхуках, а не то, что уходит в запросе.

  • Приход вебхука принят за оплату

    Смотрите на data.status. underpaid — это не оплачено, и поступление в монете, которую счёт не просил, тоже не делает его оплаченным. Отгружайте по paid или overpaid и ни по чему другому.

  • Метка времени не проверяется

    Сама по себе подпись не истекает никогда. Без окна ±5 минут по X-Paysell-Timestamp однажды перехваченная доставка воспроизводится когда угодно и проходит проверку.

  • Подпись проверяется по пересобранному JSON

    Разберите тело и соберите обратно — байты изменятся (порядок ключей, пробелы), и HMAC перестанет совпадать. Подписывайте сырые байты ровно в том виде, в каком они пришли.

  • Нет дедупликации

    Один и тот же event_id рано или поздно придёт дважды: мы повторяем, пока не получим 2xx, а потерянный на обратном пути ответ выглядит отсюда как неудача. Второй приход должен не делать ничего.

  • Используется заголовок Idempotency-Key

    Этот API читает idempotency_key из тела запроса, а заголовок не читает вовсе. Повтор без этого поля откроет второй счёт на тот же заказ.

  • Считается, что detail всегда объект

    Это {code, message} для всего, что решаем мы или ядро, и список ошибок по полям, когда проверку не прошло само тело. Посмотрите, что именно пришло, прежде чем читать detail.code.

Возвраты#

Как вернуть деньги покупателю.

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

Чтобы вернуть деньги покупателю, откройте тикет в поддержке из кабинета: invoice_id или tx_hash, сумма и адрес, куда отправлять. Оператор проверит платёж, спишет деньги с вашего баланса и ответит в том же тикете. Закладывайте рабочий день, а не минуту.

Два следствия, под которые стоит проектировать. Переплата зачисляется вам целиком — мы себе ничего не оставляем, — поэтому вернуть разницу покупателю, приславшему лишнее, ваше решение, и идёт оно тем же путём. И недоплаченный счёт, пока он открыт, — это не случай для возврата: деньги на вашем балансе, адрес по-прежнему под наблюдением, покупатель может просто доплатить. Решать что-то приходится только после льготного срока, когда счёт уходит в expired с деньгами.

Тестирование#

Как проверить интеграцию перед запуском.

Ключи здесь боевые: любой выданный ключ — это sk_live_ против продакшн-ядра и mainnet TON. Отдельного тестового окружения нет, и это даже к лучшему: вы проверяете ровно тот путь, по которому пойдут настоящие заказы.

Поэтому проверяйте так, как проверяют всё, что касается настоящих денег: на маленьких суммах. Выставьте счёт на минимум (0.1 TON или 3 USDT), оплатите его со своего кошелька и пройдите весь путь — страница оплаты, вебхук, проверка подписи, перевод заказа в оплаченные. Комиссия при этом берётся, монеты действительно двигаются.

Без затрат можно проверить почти всё остальное: создание и чтение счёта, отмену, 422 на кривой сумме, 401 на неверном ключе и собственную проверку подписи — подпишите своим секретом произвольное тело и скормите его своему же обработчику. Настоящей оплаты требует только последний шаг: реальный вебхук payment.credited.

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

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

Для ИИ-агентов и языковых моделей#

Машинные копии этой страницы и готовый запрос.

Всё, что есть на этой странице, существует и в виде, который модель прочитает сама. Дайте помощнику один из этих адресов вместо того, чтобы вставлять в чат скриншоты документации.

Три файла

ФайлЧто этоДля чего
/llms-full.txtВся документация одним markdown-файлом: методы, поля, состояния, лимиты, ошибки, вебхуки с рабочим кодом проверки подписи, комиссия, страница оплаты, чек-лист.Вставить модели в контекст или дать агенту скачать. Начинать стоит отсюда.
/llms.txtКороткий указатель по стандарту llms.txt: что такое Paysell, пять правил, от которых зависит работоспособность интеграции, и ссылки на остальное.Чтобы агент дальше разобрался сам.
/openapi.jsonOpenAPI 3.1, собранный из моделей работающего приложения, вместе с описанием обоих событий-вебхуков.Сгенерировать клиент или загрузить в любой инструмент, понимающий OpenAPI.

Готовый запрос

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

prompt
Read https://paysell.me/llms-full.txt and implement Paysell payments in my <stack>:
create invoices (POST /api/merchant/v1/invoices, Bearer sk_live_ key, amount as a
decimal string in normal units), redirect the buyer to payment_url, verify webhook
signatures (HMAC-SHA256 over "{timestamp}.{raw_body}", header X-Paysell-Signature,
reject anything whose X-Paysell-Timestamp is more than 300 seconds off), deduplicate
by event_id, answer 2xx within 10 seconds, and mark orders paid only on a
payment.credited event whose data.status is "paid" or "overpaid".

Как скормить конкретному инструменту

  • Агентам с доступом в сеть — Claude Code, Cursor, Windsurf и подобным: дайте ссылку на /llms-full.txt. Одно скачивание, настраивать нечего.
  • В окне чата — ChatGPT, Claude, Gemini: вставьте содержимое /llms-full.txt в разговор или приложите файлом. Оно написано так, чтобы поместиться в одно сообщение.
  • Инструментам по OpenAPI — генераторам клиентов, Postman, описанию инструментов агента: укажите https://paysell.me/openapi.json. В поле servers уже стоит боевой базовый адрес, поэтому сгенерированные вызовы пойдут куда надо.

Чек-лист перед запуском#

Десять пунктов перед запуском.

  • Ключ живёт только на сервере, никогда — в браузерном JavaScript.
  • Подпись вебхука проверяется по строке "{timestamp}.{raw_body}" и сравнивается за постоянное время.
  • Доставки старше пяти минут отвергаются, часы сервера синхронизированы по NTP.
  • Повторный X-Paysell-Event-Id во второй раз ничего не делает.
  • Вебхук отвечает 2xx за десять секунд; медленная работа — после ответа.
  • Адрес вебхука — https:// на доменном имени и порту 443, без редиректа перед ним.
  • Пропущенный вебхук не смертелен: счёт читается на странице «спасибо за заказ» или при регулярной сверке.
  • idempotency_key создаётся один раз на заказ и переиспользуется при повторах.
  • Суммы уходят в обычных единицах строкой; числа из вебхука читаются как минимальные единицы.
  • Адрес показывается ровно так, как пришёл, без изменений.
  • overpaid и underpaid обработаны, а не только paid; expired тоже может нести paid_minor.
  • Товар отгружается по status: paid или overpaid, а не по факту прихода вызова.
  • 429 обрабатывается ожиданием Retry-After, а не немедленным повтором.
  • Баланс читается у нас, а не ведётся отдельно как истина.

Что-то непонятно?

Если эта страница не ответила на ваш вопрос — это пробел в документации, и о нём стоит сообщить. Напишите из личного кабинета, и мы исправим страницу, а не только дадим ответ.