Перейти к содержанию

Вебхуки

Подписка на вебхуки говорит 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 шестнадцатеричных символа, и прочитать его можно в любой момент: это не значение, которое показывают один раз при создании и больше никогда. Забирайте его при старте (или кешируйте ненадолго), но не зашивайте в код — тогда ротация секрета в кабинете не потребует передеплоя.

statusACTIVE или 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)
}
import hmac
from hashlib import sha256

def is_valid_signature(raw_body: bytes, header: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(secret.encode(), raw_body, sha256).hexdigest()
    return hmac.compare_digest(expected, header or "")
import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
)

func IsValidSignature(rawBody []byte, header, secret string) bool {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(rawBody)
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(header))
}

Подписывайте сырые байты

Проверяйте подпись до разбора 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. Ждать повторной отправки бесполезно, да и тело вебхука всегда было лишь их снимком.