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

Вебхуки

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