Вебхуки¶
Подписка на вебхуки говорит NullSwap, куда слать POST, когда с одним из ваших инвойсов что-то
происходит. Это штатный способ узнать об оплате; опрос инвойса — инструмент сверки, а не интеграции.
Подписки создаются в кабинете, а не через API
Добавить, изменить или удалить вебхук — административное действие, и API-ключ такого права не
получает никогда. Откройте своего мерчанта в кабинете и укажите там URL эндпоинта и события. На
/api/v1/webhooks нет ни POST, ни PUT, ни DELETE.
API даёт то, что нужно бэкенду: секрет для проверки подписей и журнал доставок, по которому видно, почему ожидаемый хук так и не пришёл.
| Метод | Путь | Назначение |
|---|---|---|
GET |
/api/v1/webhooks |
Список ваших подписок |
GET |
/api/v1/webhooks/{webhookId} |
Чтение одной подписки |
GET |
/api/v1/webhooks/{webhookId}/deliveries |
История доставок |
Чтение подписки¶
curl "https://api.pay.nullswap.com/api/v1/webhooks?status=ACTIVE&limit=20" \
-H "x-api-key: sk_live_..."
{
"data": [{
"id": "c3d4e5f6-a7b8-4901-a234-5b6c7d8e9f01",
"merchantId": "9f1d2c3b-4a5e-4f60-8b71-2c3d4e5f6a7b",
"url": "https://shop.example.com/hooks/nullswap",
"secret": "8f14e45fceea167a5a36dedd4bea2543a1b2c3d4e5f60718293a4b5c6d7e8f90",
"events": ["invoice.paid", "invoice.expired"],
"status": "ACTIVE",
"createdAt": "2026-01-01T12:00:00.000Z"
}],
"pagination": { "offset": 0, "limit": 20, "total": 1, "order": "DESC" }
}
secret — 64 шестнадцатеричных символа, и прочитать его можно в любой момент: это не значение,
которое показывают один раз при создании и больше никогда. Забирайте его при старте (или кешируйте
ненадолго), но не зашивайте в код — тогда ротация секрета в кабинете не потребует передеплоя.
status — ACTIVE или DISABLED. Отключённая подписка перестаёт ставить новые события в очередь,
но уже запланированные доставки не отменяет: какое-то время после отключения ваш эндпоинт ещё будет
получать «хвост».
События¶
| Событие | Когда срабатывает |
|---|---|
invoice.created |
Создан инвойс |
invoice.payment_detected |
Депозит виден в сети, без подтверждений |
invoice.payment_confirmed |
Депозит набрал нужную глубину, и скрининг вынес вердикт |
invoice.payment_failed |
Депозит откатился или потерян из-за реорганизации сети |
invoice.partially_paid |
Инвойс перешёл в PARTIALLY_PAID |
invoice.paid |
Инвойс перешёл в PAID |
invoice.swapping |
Расчёт начал обмен средств |
invoice.completed |
Расчёт завершён |
invoice.failed |
Расчёт не удался |
invoice.expired |
Срок вышел, а достаточной оплаты так и не поступило |
invoice.unexpected_payment |
Депозит при обнаружении классифицирован как UNEXPECTED |
Подписывайтесь только на то, на что действительно реагируете. 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, шлётinvoice.payment_failed; событияinvoice.unpaidне существует. Новый статус ищите в теле. invoice.partially_paidможет срабатывать несколько раз, по мере поступления новых депозитов.- Статические кошельки вообще не порождают вебхуков. Сверяйте их опросом
GET /api/v1/static-wallets/payments.
Формат доставки¶
Каждая доставка — это POST с телом в JSON и четырьмя заголовками:
| Заголовок | Значение |
|---|---|
Content-Type |
application/json |
X-Nullswap-Event |
Тип события, например invoice.paid |
X-Nullswap-Delivery |
UUID доставки — дедуплицируйте по нему |
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",
"chainId": "1c9a7f36-5f2b-4a83-9d51-0e6b7c8d9a10",
"tokenId": "3b7e1a48-2c6d-4e9f-8a01-5d4c3b2a1908",
"expectedAmount": "25500000",
"acceptsAnyToken": false,
"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.
Идентификатора доставки в теле тоже нет — он приходит только в заголовке 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,
"fromAddress": "TJ8k...2Ba7",
"amount": "25500000",
"kind": "EXPECTED",
"unexpectedReason": null,
"resolution": "PENDING",
"status": "DETECTED",
"confirmations": 0,
"amlStatus": null,
"amlResultId": null,
"createdAt": "2026-01-01T12:04:11.000Z",
"updatedAt": "2026-01-01T12:04:11.000Z"
}
}
}
От платежа, который вы читаете через REST, он отличается дважды: в data.payment есть hash и
blockNumber, которых в REST-представлении нет, и нет refundAddress и refundTxHash, которые там
есть. В invoice.payment_confirmed поле blockNumber равно null.
Проверка подписи¶
Вычислите HMAC-SHA256(secret, raw_request_body) и сравните за постоянное время с hex-значением из
X-Nullswap-Signature. Секрет используется как строка UTF-8, а не декодируется из hex.
import { createHmac, timingSafeEqual } from "node:crypto"
// express.raw({ type: "application/json" }) — req.body должен быть ровно теми байтами, что мы отправили.
export function isValidSignature(rawBody, header, secret) {
const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex")
const a = Buffer.from(expected)
const b = Buffer.from(header ?? "")
return a.length === b.length && timingSafeEqual(a, b)
}
Подписывайте сырые байты
Проверяйте подпись до разбора JSON и по телу ровно в том виде, в каком оно пришло. Повторная сериализация разобранного JSON меняет порядок ключей и пробелы, и подпись уже не совпадёт никогда. Запрос с несовпавшей подписью отклоняйте — а не записывайте предупреждение в лог и обрабатывайте дальше.
Отметки времени в подписи нет, поэтому сама по себе она от повторной отправки не защищает.
Безвредным повтор делает именно дедупликация по X-Nullswap-Delivery.
Если несколько ваших подписок смотрят на один и тот же URL, каждая порождает свою доставку — со своим идентификатором и подписанную своим секретом. Проверяйте секретом той подписки, которую опознали, либо перебирайте секреты по очереди.
Повторы, порядок и идемпотентность¶
Каждая попытка прерывается по таймауту через 10 секунд. Всё, кроме 2xx, считается неудачей и
повторяется со следующей задержкой:
| Попытка | Ожидание |
|---|---|
| 2 | 5 секунд |
| 3 | 30 секунд |
| 4 | 2 минуты |
| 5 | 10 минут |
| 6 и далее | 1 час, циклически |
Повторы не прекращаются
Ни лимита попыток, ни очереди недоставленных сообщений, куда доставку в итоге сбрасывают, здесь
нет. 4xx повторяется точно так же, как 5xx: ответ 410 Gone нас не остановит. Эндпоинт,
пролежавший сутки, после восстановления получит всё накопившееся разом.
Именно из-за этого накопления два свойства обработчика обязательны:
- Дедуплицируйте по
X-Nullswap-Delivery. Один и тот же идентификатор доставки может прийти несколько раз. Записывайте его и повторы игнорируйте. - Не рассчитывайте на порядок. Доставки уходят пачками, и однажды сорвавшаяся отстаёт от более
свежих —
invoice.paidвполне может прийти раньшеinvoice.partially_paid. Считайте каждое тело снимком инвойса на момент события, а не разницей, и пропускайте события про состояние, которое вы уже прошли.
Отвечайте 2xx, как только сохранили событие. Всё медленное — исполнение заказа, письма,
бухгалтерию — делайте уже после ответа. Обработчик, которому нужно больше 10 секунд, будет получать
всё по два раза.
Минимальный обработчик выглядит так:
1. прочитать сырое тело
2. проверить X-Nullswap-Signature, отклонить при несовпадении
3. INSERT идентификатора X-Nullswap-Delivery; при конфликте вернуть 200 и остановиться
4. разобрать тело и проигнорировать событие, если data.status отстаёт от записанного у вас
5. вернуть 200
6. сделать основную работу асинхронно
Просмотр доставок¶
curl "https://api.pay.nullswap.com/api/v1/webhooks/{webhookId}/deliveries?status=FAILED&limit=50" \
-H "x-api-key: sk_live_..."
Фильтры по id, invoiceId, eventType и status (PENDING, DELIVERED, FAILED), с обычными
limit / offset / sortBy / order.
{
"id": "e5f6a7b8-c9d0-4123-a456-7b8c9d0e1f23",
"webhookId": "c3d4e5f6-a7b8-4901-a234-5b6c7d8e9f01",
"invoiceId": "b2f4a6c8-0d1e-4f23-9a45-6b7c8d9e0f11",
"eventType": "invoice.paid",
"payload": "{\"event\":\"invoice.paid\",...}",
"status": "PENDING",
"attempts": 4,
"lastError": "Receiver responded with 500",
"nextRetryAt": "2026-01-01T12:20:00.000Z",
"deliveredAt": null,
"createdAt": "2026-01-01T12:07:52.000Z"
}
payload — это ровно та строка JSON, которая была или будет отправлена: те же байты, по которым
считалась подпись.
Сюда и стоит смотреть, когда ожидаемый хук так и не пришёл: здесь различаются три случая, с вашей стороны выглядящие одинаково.
| Что вы видите | Что это значит |
|---|---|
| Нет записи по этому инвойсу и событию | Событие вообще не срабатывало |
status: FAILED или PENDING с attempts > 0 и lastError |
Мы пытались, ваш эндпоинт отказал |
status: DELIVERED с deliveredAt |
Мы отправили и получили 2xx — потеря на вашей стороне |
В merchant API нет эндпоинта повторной доставки
Если доставка, на которую вы уже ответили 2xx, у вас потерялась, восстанавливайте состояние
чтением инвойса — GET /api/v1/invoices/{invoiceId} и
GET /api/v1/invoices/{invoiceId}/payments. Ждать повторной отправки бесполезно, да и тело
вебхука всегда было лишь их снимком.