Принимайте крипто-платежи
Paysell проводит расчёты в TON и USDT в сети TON. Вы создаёте счёт, мы выдаём ссылку, а вы получаете подписанный колбэк, когда деньги подтверждены в блокчейне и зачислены на баланс.
Обзор#
Что делает Paysell, а что — нет.
Paysell — это платёжный процессинг, а не кошелёк. Вы никогда не имеете дела с приватными ключами, не следите за блокчейном и не решаете, когда транзакция окончательна — это берём на себя мы.
У каждого счёта — свой адрес для приёма платежа. Когда покупатель его оплачивает, мы дожидаемся подтверждения перевода сетью, вычитаем комиссию и зачисляем остаток на ваш баланс. Выводить можно на любой адрес.
Как проходит платёж#
Шесть шагов, большинство из которых — наши.
Шесть шагов, большинство из которых — наши:
- 1
Клиент нажимает «Оплатить»
Ваш сервер вызывает наш API с суммой и своим номером заказа.
- 2
Мы выдаём адрес
Свежий адрес для приёма берётся из заранее подготовленного пула и привязывается к этому счёту. Один адрес принадлежит ровно одному открытому счёту — так платёж сопоставляется с ним.
- 3
Клиент отправляет монеты
Он сканирует QR-код или копирует адрес. Отправьте его на
payment_url, который мы возвращаем, — страница уже всё показывает: сумму, адрес, QR-код, таймер, статус в реальном времени. - 4
Мы замечаем перевод
Опрашиваются два независимых источника данных блокчейна, и их ответы сравниваются. Если они расходятся, мы останавливаемся, а не выбираем более удобный ответ.
- 5
Мы ждём финальности
Включение в мастерчейн плюс три блока сверху. Примерно пятнадцать секунд — платёж, который выглядит подтверждённым, а потом исчезает, был бы вашей потерей, поэтому мы на такой риск не идём.
- 6
Зачислено, и вам сообщили
Комиссия вычитается, остаток попадает на ваш баланс, а на ваш сервер уходит подписанный вебхук с вашим
order_id.
От оплаты до колбэка — около минуты: примерно пятнадцать секунд на подтверждения сети, остальное — наш проход по отслеживаемым адресам.
Куда идут деньги#
Комиссия и от чего она считается.
Комиссия — 0,2%, фиксируется для вашего магазина в момент регистрации. Если стандартная ставка изменится позже, ваша не изменится — она записывается в каждый счёт как число, а не как ссылка на настройку.
Комиссия берётся с того, что реально пришло, а не с того, что было запрошено в счёте. Выставили счёт на 5 USDT, а пришло 20 — комиссия считается с 20. Недоплатили — считается с того, что пришло.
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
Создайте магазин
В личном кабинете. Он сразу начинает принимать платежи — без ожидания проверки. Верификация проходит в фоне: деньги нового магазина придерживаются до её окончания и становятся доступны к выводу сразу после неё. Приём платежей она не ограничивает, а заработанное другими вашими магазинами не трогает.
- 2
Выпустите API-ключ
Ваш магазин → API-ключи → Новый ключ. Ключ и секрет вебхука показываются один раз и больше никогда. Храните их как пароль от базы данных и никогда не отправляйте в браузер.
- 3
Создайте счёт
Один запрос с вашего сервера — одна ссылка в ответ. Четыре примера ниже отправляют ровно одно и то же.
- 4
Отправьте покупателя на
payment_urlЭто и есть вся страница оплаты — сумма, адрес, QR-код, обратный отсчёт, живое состояние, — и собирать ничего не нужно. Что именно видит покупатель, показано в разделе Страница оплаты.
- 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 из ответа. Всё готово — остальное придёт вебхуком.
Что делать дальше
- Написать приёмник вебхуков — Вебхуки и Готовый приёмник. На этой странице нет ничего важнее: именно он превращает платёж в оплаченный заказ.
- Обработать
underpaidиoverpaid, а не толькоpaid— см. Справочник состояний. - Прочитать Типичные ошибки и пройти чек-лист перед запуском, прежде чем направлять сюда настоящих покупателей.
Аутентификация#
Ваш API-ключ и как он используется.
Каждый запрос несёт ваш ключ в заголовке Authorization:
Authorization: Bearer sk_live_IH4SNdnYsv-yabTuPCVln4vOvvGoEyxAВсе выдаваемые здесь ключи начинаются с sk_live_. Префикс sk_test_ бывает только у сборки, направленной на тестовую сеть, а такой сборки мы не предлагаем — см. Тестирование. Мы храним необратимый хеш, а не сам ключ, поэтому никто, включая нас, не может показать его вам снова. Потеряли? Выпустите новый и отзовите старый.
Магазин определяется по ключу, поэтому ни один запрос не принимает id магазина. Ключ может действовать только в рамках своего магазина.
В адресе есть версия: /api/merchant/v1/…. Внутри версии мы только добавляем поля — ничего не переименовывается и не меняет смысл втихую. Изменение, которое сломало бы ваш код, получает новый префикс /v2, а /v1 продолжает работать заранее объявленный срок.
Все методы разом#
Четыре вызова, три из них с ключом.
Это весь мерчантский API. Балансов, выводов и истории в нём нет — они живут в кабинете, где на них смотрит человек.
| Путь | Метод | Доступ | Что делает |
|---|---|---|---|
| /invoices | POST | API-ключ | Выставить счёт и получить ссылку на оплату. Подробнее. |
| /invoices/{invoice_id} | GET | API-ключ | Прочитать текущее состояние счёта. Подробнее. |
| /invoices/{invoice_id}/cancel | POST | API-ключ | Закрыть ещё открытый счёт и освободить его адрес. Подробнее. |
| /public/invoices/{invoice_id} | GET | нет | То, что читает готовая страница оплаты. Нужен, только если вы рисуете свою. Подробнее. |
Все пути — относительно https://paysell.me/api/merchant/v1. Метода со списком счетов нет, метода возврата нет — см. Возвраты.
Создание счёта#
POST /api/merchant/v1/invoices
/api/merchant/v1/invoicesТело запроса
| Поле | Тип | Обязательное | Описание |
|---|---|---|---|
| asset | string | да | TON или USDT_TON. |
| amount | string | да | Обычные единицы монеты, строкой: "5" это 5 USDT. Знаков после запятой не больше, чем есть у монеты. См. Суммы. |
| order_id | string | нет | Ваш собственный номер заказа, до 200 символов. Возвращается в каждом вебхуке — так вы сопоставляете платёж с заказом. |
| description | string | нет | До 1000 символов. Показывается покупателю на странице оплаты. |
| ttl_minutes | number | нет | Сколько минут счёт остаётся доступным для оплаты. 1–1440; не указали — берётся значение по умолчанию, сейчас это 2 часа. |
| idempotency_key | string | нет | До 200 символов. Отправьте то же значение при повторе запроса — получите тот же счёт, а не второй. Это поле тела, а не заголовок Idempotency-Key: заголовок здесь не читается. |
Ответ · 201
{
"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}
/api/merchant/v1/invoices/{invoice_id}Та же структура, что и выше, только status, paid и paid_minor отражают текущее положение дел: paid — сколько уже пришло в обычных единицах, paid_minor — то же целым числом в минимальных. Полезно как запасной вариант, если вебхук не дошёл, или на странице благодарности.
Опрашивайте не чаще, чем раз в несколько секунд, и считайте вебхуки основным каналом. Счета, принадлежащие другому магазину, отвечают 404, а не 403 — так id нельзя прощупать на существование.
Отмена счёта#
POST /api/merchant/v1/invoices/{invoice_id}/cancel
/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 от более раннего перевода, событие относится к лишнему поступлению, а не к той оплате. |
Что приходит
{
"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…"
}
}{
"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 | Транзакция в блокчейне — для ваших записей и поддержки. |
Заголовки каждой доставки
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-Signature | sha256= и следом HMAC в hex. Как проверять — ниже. |
Проверка подписи
Каждый запрос подписывается секретом вебхука, который показывается один раз при создании ключа. Подпись — это HMAC-SHA256(secret, "{timestamp}.{raw_body}"): метка времени из X-Paysell-Timestamp, точка, затем байты тела. Проверяйте её перед тем, как действовать: иначе любой, кто узнает ваш URL, сможет подсунуть вам «оплаченный» заказ.
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:
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() отдаёт разобранный объект, и байты, собранные из него обратно, — уже не те, которые мы подписывали.
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.
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 в ответе |
|---|---|---|---|
| TON | 9 | "1.5" | "1500000000" |
| USDT_TON | 6 | "1.5" | "1500000" |
Строкой, а не числом, потому что JSON-число — это IEEE-754 double, и крупная сумма в наноTON перестаёт помещаться в него точно. Знаков после запятой больше, чем есть у монеты, — это 422, а не повод молча округлить ваши деньги. В вебхуках наоборот: amount, fee и credited там целые числа в минимальных единицах, потому что ту сторону читает код, а не человек.
// 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 USDT | 422 |
| Максимальный счёт | 7000 TON · 10000 USDT | 422 |
| Счетов в час на магазин | 60 | 429 |
| Одновременно открытых счетов | 20, растёт с каждым оплаченным, до 200 | 429 |
| Срок жизни счёта | от 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`: формулировка может измениться в любой момент, код — нет.
{
"detail": {
"code": "invalid_input",
"message": "invoice amount below the minimum: 0.010000 USDT_TON, minimum 3.000000 USDT_TON"
}
}{
"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_key | 401 | Ключ отсутствует, испорчен, неизвестен или отозван. Все четыре случая отвечают одинаково, поэтому ключ нельзя нащупать перебором. |
| not_found | 404 | Объекта нет либо он принадлежит другому магазину. |
| invalid_input | 422 | Запрос не прошёл проверку в ядре — неверная сумма, лишние знаки после запятой, сумма за границами счёта. |
| conflict | 409 | Действие противоречит текущему состоянию — например, отмена счёта, который уже не открыт. |
| too_many_requests | 429 | Ограничение частоты: счетов в час, открытых счетов или запросов в минуту. Retry-After говорит, сколько ждать. |
| cbc_unreachable | 502 | Не удалось достучаться до ядра процессинга. Повторите с тем же idempotency_key. |
| webhook_url_rejected | 422 | Только при сохранении ключа: адрес вебхука не прошёл проверки выше. 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.json | OpenAPI 3.1, собранный из моделей работающего приложения, вместе с описанием обоих событий-вебхуков. | Сгенерировать клиент или загрузить в любой инструмент, понимающий OpenAPI. |
Готовый запрос
Скопируйте, замените стек и отдайте помощнику. В запросе названы четыре вещи, на которых чаще всего ошибаются, — чтобы ответ не пришлось потом переделывать.
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, а не немедленным повтором.- Баланс читается у нас, а не ведётся отдельно как истина.
Что-то непонятно?
Если эта страница не ответила на ваш вопрос — это пробел в документации, и о нём стоит сообщить. Напишите из личного кабинета, и мы исправим страницу, а не только дадим ответ.