Вебхуки¶
Подписка на вебхуки говорит NullSwap, куда слать POST, когда с одним из ваших инвойсов или
конвертаций что-то происходит. Это штатный способ узнать об оплате; опрос инвойса — инструмент
сверки, а не интеграции.
Подписки создаются в кабинете, а не через API
Добавить, изменить или удалить вебхук — административное действие, и API-ключ такого права не получает никогда. Откройте своего мерчанта в кабинете, войдя как его владелец, и укажите URL эндпоинта и события в разделе Настройки → Вебхуки. Остальные участники видят там подписки, но изменить их не могут.
В API мерчанта нет ни одного маршрута для вебхуков — даже чтобы прочитать подписку или её историю доставок: и то и другое находится в кабинете.
Ваши подписки¶
Секрет подписи API не возвращает
API мерчанта никогда не отдаёт секрет, которым вы проверяете подпись, — утёкший API-ключ не позволит подделать доставку. Секрет смотрите в кабинете, войдя как участник мерчанта: это 64 шестнадцатеричных символа, и там он доступен в любой момент. Храните его в хранилище секретов вашего бэкенда, а не в коде.
Подписка бывает ACTIVE или DISABLED. Отключённая подписка перестаёт ставить новые события в очередь,
но уже запланированные доставки не отменяет: какое-то время после отключения ваш эндпоинт ещё будет
их получать.
Удаление подписки в кабинете удаляет вместе с ней и её ожидающие доставки, и историю доставок.
События¶
| Событие | Когда срабатывает |
|---|---|
invoice.created |
Создан инвойс |
invoice.payment_detected |
Депозит виден в сети, без подтверждений |
invoice.payment_confirmed |
Депозит набрал нужную глубину, и скрининг вынес вердикт |
invoice.payment_failed |
Депозит откатился или потерян из-за реорганизации сети |
invoice.partially_paid |
Инвойс перешёл в PARTIALLY_PAID |
invoice.paid |
Инвойс перешёл в PAID |
invoice.swapping |
Инвойс перешёл в SWAPPING: свип его депозитного адреса отправил деньги в путь |
invoice.completed |
Инвойс перешёл в COMPLETED: ни один свип его депозитного адреса не идёт и не провалился |
invoice.failed |
Инвойс перешёл в FAILED: свип его депозитного адреса сдался, и деньги всё ещё на адресе |
invoice.expired |
Срок вышел, а достаточной оплаты так и не поступило |
invoice.unexpected_payment |
Депозит при обнаружении классифицирован как UNEXPECTED |
conversion.completed |
Конвертация завершена, целевой токен зачислен на ваш баланс |
conversion.failed |
Конвертация, запущенная вами в кабинете, не состоялась; списанное по ней вернулось |
conversion.skipped |
Свип, на который распространяется правило автоконвертации, зачислен без конвертации |
invoice.swapping, invoice.completed и invoice.failed следуют за свипами депозитного адреса
инвойса — автоматическими и теми, что вы запрашиваете.
Автоматический свип двигает инвойс, только когда тот в PAID. invoice.failed — не конец: более
поздний свип переводит инвойс дальше, в SWAPPING или COMPLETED, и соответствующее событие
приходит снова. См. жизненный цикл статусов.
События conversion.* описывают конвертации: ту, что вы запускаете в кабинете, и ту, что правило
автоконвертации применяет к свипу. См.
события о конвертациях.
Подписывайтесь только на то, на что действительно реагируете. invoice.paid — сигнал к исполнению
заказа. invoice.payment_detected годится только для индикатора прогресса: выдавать по нему товар
нельзя, неподтверждённая транзакция ещё может исчезнуть.
Имена событий сравниваются посимвольно. Масок нет: invoice.* не подпишет вас ни на что.
О чём события не сообщают¶
invoice.payment_confirmedсрабатывает независимо от того, прошёл скрининг или нет. Оно значит «у нас есть вердикт», а не «деньги хорошие». Читайтеdata.payment.amlStatusили следите заdata.paidAmount.invoice.unexpected_paymentприходит вместоinvoice.payment_detected, а не вдобавок к нему. И порождается оно только при обнаружении депозита. Если первым, что мы увидели, стало уже подтверждение, неожиданный платёж придёт к вам какinvoice.payment_confirmed— поэтому там тоже проверяйтеdata.payment.kind === "UNEXPECTED".- Собственного события об откате статуса нет. Реорганизация сети, из-за которой инвойс уходит из
PARTIALLY_PAIDобратно вWAITINGили выходит изPAID, шлётinvoice.payment_failed; событияinvoice.unpaidне существует. Новый статус ищите в теле. Откат изPAIDвPARTIALLY_PAIDвдобавок шлётinvoice.partially_paid, поставленное в очередь раньшеinvoice.payment_failed. Если после этого инвойс снова оплачен,invoice.paidпридёт второй раз. invoice.partially_paidможет срабатывать несколько раз, по мере поступления новых депозитов.- Ни одно событие не касается платежей на статические кошельки. Сверяйте их опросом
GET /api/v1/static-wallets/payments. Единственные события, которые может вызвать статический кошелёк, —conversion.completedиconversion.skipped, когда на его свип распространяется правило автоконвертации.
Формат доставки¶
Каждая доставка — это POST с телом в JSON и пятью заголовками:
| Заголовок | Значение |
|---|---|
Content-Type |
application/json |
X-Nullswap-Event |
Тип события, например invoice.paid |
X-Nullswap-Delivery |
UUID доставки — дедуплицируйте по нему |
X-Nullswap-Timestamp |
Момент отправки этой попытки в Unix-секундах, например 1767268800 |
X-Nullswap-Signature |
HMAC отметки времени и сырого тела в виде sha256=<hex> |
В теле ровно три ключа верхнего уровня:
{
"event": "invoice.paid",
"occurredAt": "2026-01-01T12:07:52.000Z",
"data": {
"id": "b2f4a6c8-0d1e-4f23-9a45-6b7c8d9e0f11",
"merchantId": "9f1d2c3b-4a5e-4f60-8b71-2c3d4e5f6a7b",
"depositWalletId": "7e2a9c14-8b3d-4c56-a789-0f1e2d3c4b5a",
"depositAddress": "TQ5s...9Xk2",
"depositTag": null,
"depositXAddress": null,
"chainId": "1c9a7f36-5f2b-4a83-9d51-0e6b7c8d9a10",
"tokenId": "3b7e1a48-2c6d-4e9f-8a01-5d4c3b2a1908",
"expectedAmount": "25500000",
"acceptsAnyToken": false,
"autoSweep": true,
"paidAmount": "25500000",
"pendingAmount": "0",
"externalId": "order-10241",
"status": "PAID",
"createdAt": "2026-01-01T12:00:00.000Z",
"expiresAt": "2026-01-01T13:00:00.000Z",
"updatedAt": "2026-01-01T12:07:52.000Z"
}
}
data — снимок инвойса на момент события. Он совпадает с тем, что вернул бы
GET /api/v1/invoices/{invoiceId}, с одним отличием: в теле вебхука нет поля amlStatus.
depositTag и depositXAddress заполнены только у инвойсов RIPPLE, а во всех остальных случаях
равны null; на RIPPLE именно на них должен платить покупатель — см.
Тег назначения на RIPPLE.
Идентификатора доставки в теле тоже нет — он приходит только в заголовке X-Nullswap-Delivery.
В событиях о платежах есть data.payment¶
В событиях invoice.payment_detected, invoice.payment_confirmed, invoice.payment_failed и
invoice.unexpected_payment снимок инвойса дополняется объектом payment, описывающим депозит,
который вызвал событие:
{
"event": "invoice.payment_detected",
"occurredAt": "2026-01-01T12:04:11.000Z",
"data": {
"id": "b2f4a6c8-0d1e-4f23-9a45-6b7c8d9e0f11",
"status": "PAYMENT_DETECTED",
"paidAmount": "0",
"pendingAmount": "25500000",
"payment": {
"id": "d4e5f6a7-b8c9-4012-a345-6b7c8d9e0f12",
"chainTransactionId": "aa11bb22-cc33-4d44-8e55-6f7788990011",
"chainTransferId": "bb22cc33-dd44-4e55-8f66-778899001122",
"chainId": "1c9a7f36-5f2b-4a83-9d51-0e6b7c8d9a10",
"tokenId": "3b7e1a48-2c6d-4e9f-8a01-5d4c3b2a1908",
"hash": "0xabcdef...7890",
"blockNumber": 21000042,
"blockTimestamp": "2026-01-01T12:03:59.000Z",
"fromAddress": "TJ8k...2Ba7",
"amount": "25500000",
"kind": "EXPECTED",
"unexpectedReason": null,
"resolution": "ACCEPTED",
"status": "DETECTED",
"confirmations": 0,
"amlStatus": null,
"amlResultId": null,
"createdAt": "2026-01-01T12:04:11.000Z",
"updatedAt": "2026-01-01T12:04:11.000Z"
}
}
}
data.payment отличается от платежа, который вы читаете через REST: в нём есть chainTransferId,
hash, blockNumber и blockTimestamp, которых в REST-представлении нет, и нет invoiceId,
refundAddress, refundTag и refundTxHash, которые там есть. blockTimestamp — время, когда был
добыт блок с транзакцией, по данным самой сети, в ISO-8601 UTC; createdAt — когда мы впервые
увидели платёж, а occurredAt — когда возникло событие. В invoice.payment_confirmed поля
blockNumber и blockTimestamp равны null, поэтому сохраняйте их из предшествующего
invoice.payment_detected. У депозита, впервые увиденного уже при подтверждении, более раннего
события нет, и номер и время его блока через вебхук вы не получите никогда.
В событиях о конвертациях приходит конвертация¶
В conversion.completed, conversion.failed и conversion.skipped поле data — это конвертация,
а не инвойс:
{
"event": "conversion.completed",
"occurredAt": "2026-01-01T12:15:30.000Z",
"data": {
"id": "f7a8b9c0-d1e2-4345-a678-9b0c1d2e3f45",
"shortId": "CNV-1042",
"merchantId": "9f1d2c3b-4a5e-4f60-8b71-2c3d4e5f6a7b",
"source": "AUTO",
"status": "COMPLETED",
"chainId": "1c9a7f36-5f2b-4a83-9d51-0e6b7c8d9a10",
"srcTokenId": "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d",
"dstTokenId": "3b7e1a48-2c6d-4e9f-8a01-5d4c3b2a1908",
"amountIn": "10000000000000000",
"amountOut": "25480000",
"fee": "382200",
"invoiceId": "b2f4a6c8-0d1e-4f23-9a45-6b7c8d9e0f11",
"staticWalletId": null,
"sweepId": "a1b2c3d4-e5f6-4a78-9b01-2c3d4e5f6a78",
"skipReason": null,
"createdAt": "2026-01-01T12:15:30.000Z",
"settledAt": "2026-01-01T12:15:30.000Z"
}
}
Пример сокращён: в теле есть ещё котировка, курсы, суммы в USD и транзакция, которой выполнена конвертация.
| Поле | Значение |
|---|---|
source |
MANUAL — вы сконвертировали часть баланса в кабинете. AUTO — правило автоконвертации сконвертировало свип по пути на ваш баланс |
status |
COMPLETED, FAILED (только MANUAL) или SKIPPED (только AUTO) |
amountIn |
Сколько базовых единиц srcTokenId продано |
amountOut |
Сколько базовых единиц dstTokenId получено |
fee |
Только AUTO: сколько удержано из amountOut до зачисления, в dstTokenId, — комиссия мерчанта и сетевая комиссия свипа |
invoiceId, staticWalletId, sweepId |
Только AUTO: свип, который сконвертирован, и откуда пришли его деньги |
skipReason |
При SKIPPED: почему свип прошёл без конвертации — те же значения, что у conversionSkipReason свипа |
Маршрута, который читал бы конвертацию обратно, в merchant API нет. Вебхук, а также
creditedTokenId, amountIn и amountOut сконвертированного свипа — это всё, что сообщает API.
Проверка подписи¶
Склейте значение X-Nullswap-Timestamp, точку и сырое тело запроса, вычислите
HMAC-SHA256(secret, timestamp + "." + raw_request_body) и сравните за постоянное время с
hex-значением из X-Nullswap-Signature. Секрет используется как строка UTF-8, а не декодируется из
hex. Берите отметку времени ровно в том виде, в каком она пришла в заголовке, не разбирая и не
форматируя её заново.
import { createHmac, timingSafeEqual } from "node:crypto"
const TOLERANCE_SECONDS = 300
// express.raw({ type: "application/json" }) — req.body должен быть ровно теми байтами, что мы отправили.
export function isValidSignature(rawBody, timestamp, header, secret) {
if (!/^\d+$/.test(timestamp ?? "")) return false
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false
const expected =
"sha256=" + createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex")
const a = Buffer.from(expected)
const b = Buffer.from(header ?? "")
return a.length === b.length && timingSafeEqual(a, b)
}
import hmac
import time
from hashlib import sha256
TOLERANCE_SECONDS = 300
def is_valid_signature(raw_body: bytes, timestamp: str, header: str, secret: str) -> bool:
if not (timestamp or "").isdigit():
return False
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
signed = timestamp.encode() + b"." + raw_body
expected = "sha256=" + hmac.new(secret.encode(), signed, sha256).hexdigest()
return hmac.compare_digest(expected, header or "")
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"time"
)
const toleranceSeconds = 300
func IsValidSignature(rawBody []byte, timestamp, header, secret string) bool {
sent, err := strconv.ParseUint(timestamp, 10, 63)
if err != nil {
return false
}
age := time.Now().Unix() - int64(sent)
if age > toleranceSeconds || age < -toleranceSeconds {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "."))
mac.Write(rawBody)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(header))
}
Подписывайте сырые байты
Проверяйте подпись до разбора JSON и по телу ровно в том виде, в каком оно пришло. Повторная сериализация разобранного JSON меняет порядок ключей и пробелы, и подпись уже не совпадёт никогда. Запрос с несовпавшей подписью отклоняйте — а не записывайте предупреждение в лог и обрабатывайте дальше.
Защита от повторной отправки¶
Верная подпись доказывает, что запрос отправили мы, но не что его отправили только что. Тот, кому попала в руки наша доставка — из лога прокси, трекера ошибок или сервиса для просмотра запросов, которым пользовались при отладке, — может отправить её вам ещё раз, и подпись по-прежнему сойдётся. Отличить такую копию от свежей доставки позволяет отметка времени: она входит в подпись, и изменить её, не сломав подпись, нельзя.
Отклоняйте запрос, у которого X-Nullswap-Timestamp отличается от времени на ваших часах больше чем
на 5 минут в любую сторону, как это делают примеры выше. Допуск выбираете вы. Держите часы
сервера синхронизированными по NTP, иначе проверку начнут проваливать и настоящие доставки.
Каждая попытка, включая повторную, подписывается временем своей отправки. Доставка, повторённая через час, приходит со свежей отметкой времени, так что наши повторы проверка не отклоняет никогда.
Проверка времени не заменяет дедупликацию
Повтор — это новая попытка с новой отметкой времени и тем же X-Nullswap-Delivery, и она может
прийти, когда вы уже обработали доставку, — например, если обработчик не уложился в таймаут.
Безвредными такие повторы делают только дедупликация и обработка по состоянию, описанные в
разделе Повторы, порядок и идемпотентность.
Если несколько ваших подписок смотрят на один и тот же URL, каждая порождает свою доставку — со своим идентификатором и подписанную своим секретом. Проверяйте секретом той подписки, которую опознали, либо перебирайте секреты по очереди.
Повторы, порядок и идемпотентность¶
Каждая попытка прерывается по таймауту через 10 секунд. Всё, кроме 2xx, считается неудачей и
повторяется со следующей задержкой:
| Попытка | Ожидание |
|---|---|
| 2 | 5 секунд |
| 3 | 30 секунд |
| 4 | 2 минуты |
| 5 | 10 минут |
| 6 и далее | 1 час, циклически |
Повторы сами не прекращаются
Ни лимита попыток, ни очереди недоставленных сообщений, куда доставку в итоге сбрасывают, здесь
нет. 4xx повторяется точно так же, как 5xx: ответ 410 Gone нас не остановит. Эндпоинт,
пролежавший сутки, после восстановления получит всё накопившееся разом. Остановить повторы можно
только отменой доставки, пока она ещё ожидает отправки: в кабинете, в разделе
Настройки → Вебхуки → Доставки, владелец мерчанта может выбрать ожидающие доставки и
отменить их. У отменённой доставки status: CANCELLED, и снова она не отправляется, пока её не
повторят с того же экрана.
Из-за этого накопления обработчику нужны два свойства:
- Дедуплицируйте по
X-Nullswap-Delivery. Один и тот же идентификатор доставки может прийти несколько раз. Записывайте его и повторы игнорируйте. Храните идентификаторы столько, сколько к вам могут приходить повторы, — а повторы сами не прекращаются, так что речь о днях, а не о минутах. - Действуйте по состоянию, а не по факту получения. Решайте, что делать, по телу, а не по тому,
что пришёл запрос: отмечайте заказ оплаченным, если он ещё не оплачен, зачисляйте депозит один раз
на
data.payment.id. Такой обработчик безопасен, даже когда одно и то же событие приходит к нему дважды под разными идентификаторами доставки — как бывает, когда две ваши подписки смотрят на один URL. - Не рассчитывайте на порядок. Доставки уходят пачками, и однажды сорвавшаяся отстаёт от более
свежих —
invoice.paidвполне может прийти раньшеinvoice.partially_paid. Считайте каждое тело снимком инвойса на момент события, а не разницей, и пропускайте события про состояние, которое вы уже прошли.
Отвечайте 2xx, как только сохранили событие. Всё медленное — исполнение заказа, письма,
бухгалтерию — делайте уже после ответа. Обработчик, которому нужно больше 10 секунд, отваливается по
таймауту, и каждое событие придёт к нему повторно.
Минимальный обработчик выглядит так:
1. прочитать сырое тело
2. проверить X-Nullswap-Signature и X-Nullswap-Timestamp, отклонить при несовпадении
3. INSERT идентификатора X-Nullswap-Delivery; при конфликте вернуть 200 и остановиться
4. разобрать тело и проигнорировать событие, если data.status отстаёт от записанного у вас
5. вернуть 200
6. сделать основную работу асинхронно
Просмотр доставок¶
Историю доставок через API мерчанта не получить. Смотрите её в кабинете, в разделе Настройки → Вебхуки → Доставки, когда ожидаемый хук так и не пришёл: там различаются четыре случая, с вашей стороны выглядящие одинаково.
| Что вы видите | Что это значит |
|---|---|
| Нет доставки по этому инвойсу и событию | Событие вообще не срабатывало |
PENDING с уже сделанными попытками и последней ошибкой |
Мы пытались, ваш эндпоинт отказал, и мы продолжаем повторять |
DELIVERED |
Мы отправили и получили 2xx — потеря на вашей стороне |
CANCELLED |
Владелец вашего мерчанта или поддержка NullSwap по вашей просьбе остановили доставку, пока она ещё ожидала отправки; снова мы её не отправим, пока её не повторят |
Неудачная попытка никогда не делает доставку FAILED: она возвращается в PENDING с записанной
ошибкой и запланированным повтором. FAILED записывается, только если в момент отправки подписки уже не существовало, а
удаление подписки удаляет и её историю доставок, так что такой статус вы увидите редко.
Отмена, пришедшая, пока попытка уже идёт, всё равно оставляет доставку в CANCELLED, даже если эта
последняя попытка до вас дошла. CANCELLED значит «больше не будет отправлена», а не «так и не
пришла».
В merchant API нет эндпоинта повторной доставки
Если доставка, на которую вы уже ответили 2xx, у вас потерялась, не ждите повторной отправки.
Восстанавливайте состояние чтением инвойса — GET /api/v1/invoices/{invoiceId} и
GET /api/v1/invoices/{invoiceId}/payments. Тело вебхука и так было лишь их снимком.