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

Вебхуки

Платформа доставляет уведомления о событиях вашему бэкенду через исходящие вебхуки. Когда происходит важное событие — создана транзакция, выпущен кошелёк, изменился баланс — платформа отправляет HTTP POST на указанный вами URL, подписанный HMAC-SHA256.

В этом руководстве: настройка, проверка подписи, каталог событий со схемами payload-а, гарантии доставки и рекомендации.


Как это работает

Ваш бэкенд              Платформа                     Outbox-воркер
   |                        |                            |
   |-- PUT /webhooks/outbound|                           |
   |   url + секрет подписи  |                           |
   |                        |                            |
   |                  (произошло событие)                 |
   |                        |-- запись в outbox -------->|
   |                        |                            |
   |                        |             (раз в ~5 с)   |
   |<-- POST {payload}, X-Webhook-Signature -------------|
   |-- 2xx ---------------->|                            |
  1. Регистрируете HTTPS-эндпоинт через PUT /webhooks/outbound.
  2. Платформа возвращает секрет подписи HMAC-SHA256. Сохраните его.
  3. События персистятся в надёжный outbox.
  4. Воркер доставляет события на ваш URL с гарантией at-least-once. Неудачные попытки повторяются с экспоненциальной задержкой.
  5. Ваш эндпоинт проверяет подпись, обрабатывает событие и быстро отвечает 2xx.

Настройка

1. URL

curl -X PUT {{baseUrl}}/webhooks/outbound \
  $HEADERS \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://your-backend.example.com/webhooks/custody" }'

Ответ:

{
  "url": "https://your-backend.example.com/webhooks/custody",
  "signingSecret": "a1b2c3d4...base64...",
  "subscribedEventTypes": [
    "transaction.created",
    "transaction.status.updated",
    "wallet.created",
    "balance.updated"
  ],
  "isConfigured": true
}

Секрет подписи показывается один раз

signingSecret возвращается только в том ответе, где он сгенерирован — при первичной настройке или при ротации URL. На GET /webhooks/outbound он не отдаётся, и повторный PUT с тем же URL вернёт signingSecret: null (секрет сохраняется на сервере, но повторно показать его нельзя). Сохраняйте секрет в защищённом месте (env, secrets manager) сразу после получения. Потеряли секрет — смените URL, это сгенерирует новый.

2. Подписка на конкретные события (опционально)

По умолчанию доставляются все типы событий. Сузить подписку:

curl -X PUT {{baseUrl}}/webhooks/outbound \
  $HEADERS \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-backend.example.com/webhooks/custody",
    "subscribedEventTypes": ["transaction.status.updated", "balance.updated"]
  }'

3. Тестовая доставка

curl -X POST {{baseUrl}}/webhooks/outbound/test $HEADERS

Платформа отправит синтетическое событие webhook.test на ваш URL по тому же подписному пути, что и реальные события.

4. Получить текущую конфигурацию

Узнать настроенный URL и список подписанных событий вашего тенанта:

curl {{baseUrl}}/webhooks/outbound $HEADERS

Ответ:

{
  "url": "https://your-backend.example.com/webhooks/custody",
  "signingSecret": null,
  "isConfigured": true,
  "subscribedEventTypes": [
    "transaction.created",
    "transaction.status.updated",
    "wallet.created",
    "balance.updated"
  ]
}

signingSecret всегда null на этом эндпоинте — см. предупреждение выше. isConfigured становится true после того, как и URL, и секрет заданы на сервере. subscribedEventTypes равно null, когда тенант подписан на все события (значение по умолчанию).


IP-адреса исходящих вебхуков

Все доставки вебхуков уходят с небольшого стабильного набора IP-адресов. Если ваш бэкенд за фаерволом или managed-WAF, отклоняющим неизвестных отправителей, добавьте эти IP в allowlist:

  • 31.210.65.157

Зачем allowlist, если уже есть HMAC?

HMAC-подпись доказывает, что payload подписан тем, у кого есть секрет — но не доказывает, что запрос пришёл от нас. IP-allowlist дёшево закрывает эту дыру: атакующий, не получивший ни секрет, ни хост в нашем исходящем диапазоне, не сможет даже открыть TCP-сессию к вашему эндпоинту.

Список намеренно короткий и стабильный — мы не ротируем его без предупреждения. Подпишитесь на API status page (или иной канал, о котором сообщил account manager), чтобы узнавать об изменениях заранее; любое изменение мы выдерживаем ≥7 дней до переключения трафика, чтобы вы успели обновить фаервол.


Конверт

Каждая доставка имеет общую внешнюю структуру:

{
  "eventType": "transaction.status.updated",
  "entityType": "transaction",
  "entityId": "b6c7d8e9-f0a1-2345-bcde-6789abcdef01",
  "timestamp": "2026-04-26T18:45:12.337Z",
  "data": { /* содержимое — см. каталог ниже */ }
}
Поле Описание
eventType Точечное имя из каталога. По нему диспатчите в коде.
entityType Тип сущности (transaction, wallet, balance).
entityId Стабильный ID сущности. Используйте для идемпотентности.
timestamp Время события (UTC, ISO-8601).
data Полезная нагрузка по типу события.

Проверка подписи

Каждая доставка содержит заголовок X-Webhook-Signature — hex-строка HMAC-SHA256 в нижнем регистре от сырого тела ответа, с использованием вашего signingSecret.

Подпись надо проверять на каждой доставке, до доверия к payload-у. Отклоняйте 401, если не совпала.

Node.js

const crypto = require('crypto');

function verifyWebhook(rawBody, headerSignature, signingSecret) {
  const expected = crypto
    .createHmac('sha256', signingSecret)
    .update(rawBody)
    .digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(expected, 'hex'),
    Buffer.from(headerSignature, 'hex')
  );
}

// Express: используйте `express.raw`, чтобы `req.body` был сырым Buffer.
app.post('/webhooks/custody',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const ok = verifyWebhook(
      req.body,
      req.header('X-Webhook-Signature'),
      process.env.WEBHOOK_SIGNING_SECRET
    );
    if (!ok) return res.status(401).end();

    const event = JSON.parse(req.body.toString());
    // ... обработка события
    res.status(200).end();
  }
);

Python (Flask)

import hmac, hashlib
from flask import request, abort

@app.route('/webhooks/custody', methods=['POST'])
def webhook():
    raw = request.get_data()  # сырые байты ДО парсинга JSON
    sig = request.headers.get('X-Webhook-Signature', '')
    expected = hmac.new(
        SIGNING_SECRET.encode(), raw, hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(expected, sig):
        abort(401)
    event = request.get_json()
    # ... обработка события
    return '', 200

C

using System.Security.Cryptography;
using System.Text;

[HttpPost("/webhooks/custody")]
public async Task<IActionResult> Receive()
{
    Request.EnableBuffering();
    using var ms = new MemoryStream();
    await Request.Body.CopyToAsync(ms);
    var raw = ms.ToArray();

    var headerSig = Request.Headers["X-Webhook-Signature"].ToString();
    var expected = Convert.ToHexStringLower(
        HMACSHA256.HashData(Encoding.UTF8.GetBytes(_signingSecret), raw));

    if (!CryptographicOperations.FixedTimeEquals(
            Encoding.ASCII.GetBytes(expected),
            Encoding.ASCII.GetBytes(headerSig)))
        return Unauthorized();

    var event = JsonSerializer.Deserialize<WebhookEnvelope>(raw);
    // ... обработка события
    return Ok();
}

Подпись считается по сырым байтам

Проверяйте подпись по сырому телу запроса, до любого JSON-парсера. Переформатирование (пробелы, порядок ключей) ломает подпись.


Каталог событий

Сейчас поддерживается четыре типа. Актуальный список — GET /webhooks/events/catalog.

transaction.created

Срабатывает при создании любой новой транзакции (определено пополнение, отправлен внутренний перевод, отправлен вывод).

{
  "eventType": "transaction.created",
  "entityType": "transaction",
  "entityId": "b6c7d8e9-f0a1-2345-bcde-6789abcdef01",
  "timestamp": "2026-04-26T18:45:12.337Z",
  "data": {
    "transactionId": "b6c7d8e9-f0a1-2345-bcde-6789abcdef01",
    "type": "Withdrawal",
    "status": "Submitted",
    "vaultId": "d1e2f3a4-b5c6-7890-d1e2-f3a4b5c67890",
    "vaultExternalId": "cust_12345",
    "vaultName": "Alice Johnson",
    "assetId": "c1d2e3f4-a5b6-7890-cdef-123456789abc",
    "assetSymbol": "ETH",
    "assetNetwork": "Ethereum",
    "sourceWalletId": "f4a5b6c7-d8e9-0123-fabc-456789abcdef",
    "destinationWalletId": null,
    "amount": "0.5000",
    "sourceAddress": "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18",
    "destinationAddress": "0xABC1234567890DEF1234567890abcDeF12345678",
    "destinationTag": null,
    "txHash": null,
    "networkFee": null,
    "feeCurrency": null,
    "amountUSD": "1842.50",
    "feeUSD": null,
    "note": "Ежемесячный казначейский перевод",
    "createdAt": "2026-04-26T18:45:12.000Z"
  }
}
Поле Тип Примечание
transactionId uuid Совпадает с entityId конверта.
type enum Deposit | Withdrawal | InternalTransfer.
status enum Начальный статус (всегда Submitted при создании).
vaultId / vaultExternalId / vaultName Контекст хранилища.
assetId / assetSymbol / assetNetwork Контекст актива.
sourceWalletId uuid? Null для входящих пополнений.
destinationWalletId uuid? Заполнено только для InternalTransfer.
amount decimal-string В единицах актива.
sourceAddress / destinationAddress string On-chain адреса.
destinationTag string? Memo/tag (XRP, XLM и т. д.).
txHash string? On-chain хеш; обычно null при создании.
networkFee / feeCurrency Если сеть берёт комиссию.
amountUSD / feeUSD decimal-string? Значение в USD на момент события (best-effort).
note string? Произвольный комментарий из запроса.
createdAt timestamp Момент создания записи.

transaction.status.updated

Срабатывает при каждой смене статуса: Submitted → PendingSignature → Broadcasting → Confirming → Completed (или Failed / Cancelled). Главный способ узнать о финализации пополнения или вывода.

{
  "eventType": "transaction.status.updated",
  "entityType": "transaction",
  "entityId": "b6c7d8e9-f0a1-2345-bcde-6789abcdef01",
  "timestamp": "2026-04-26T18:46:30.118Z",
  "data": {
    "transactionId": "b6c7d8e9-f0a1-2345-bcde-6789abcdef01",
    "type": "Withdrawal",
    "status": "Completed",
    "previousStatus": "Confirming",
    "vaultId": "d1e2f3a4-b5c6-7890-d1e2-f3a4b5c67890",
    "vaultExternalId": "cust_12345",
    "vaultName": "Alice Johnson",
    "assetId": "c1d2e3f4-a5b6-7890-cdef-123456789abc",
    "assetSymbol": "ETH",
    "assetNetwork": "Ethereum",
    "sourceWalletId": "f4a5b6c7-d8e9-0123-fabc-456789abcdef",
    "amount": "0.5000",
    "sourceAddress": "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18",
    "destinationAddress": "0xABC1234567890DEF1234567890abcDeF12345678",
    "txHash": "0x9af7b0c3...e4d2",
    "networkFee": "0.000421",
    "feeCurrency": "ETH",
    "amountUSD": "1842.50",
    "feeUSD": "1.05",
    "failureReason": null,
    "completedAt": "2026-04-26T18:46:28.500Z"
  }
}
Поле Примечание
status / previousStatus status === 'Completed' — зачисляйте клиенту; FailedfailureReason) — возвращайте/уведомляйте.
txHash Появляется после broadcast — ссылку на эксплорер можно показать клиенту.
failureReason Только при Failed.
completedAt При терминальном статусе.

wallet.created

Срабатывает при выпуске нового кошелька в хранилище.

{
  "eventType": "wallet.created",
  "entityType": "wallet",
  "entityId": "f4a5b6c7-d8e9-0123-fabc-456789abcdef",
  "timestamp": "2026-04-26T18:30:14.502Z",
  "data": {
    "walletId": "f4a5b6c7-d8e9-0123-fabc-456789abcdef",
    "vaultId": "d1e2f3a4-b5c6-7890-d1e2-f3a4b5c67890",
    "assetId": "c1d2e3f4-a5b6-7890-cdef-123456789abc",
    "label": "Primary ETH Wallet",
    "depositAddress": "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18",
    "depositTag": null,
    "status": "Active",
    "createdAt": "2026-04-26T18:30:14.000Z"
  }
}
Поле Примечание
depositAddress Адрес для передачи конечному клиенту.
depositTag Memo/tag для сетей, где он нужен (XRP, XLM, BNB Beacon и т. д.).
status Всегда Active при создании.

balance.updated

Срабатывает при любом изменении баланса кошелька — подтверждение пополнения, подтверждение вывода, внутренний перевод, on-chain rebase. Payload минимален — он только сигнализирует, какой баланс изменился, чтобы вы запросили актуальные числа через GET /wallets/{id}/balance.

{
  "eventType": "balance.updated",
  "entityType": "balance",
  "entityId": "11223344-5566-7788-99aa-bbccddeeff00:c1d2e3f4-a5b6-7890-cdef-123456789abc",
  "timestamp": "2026-04-26T18:46:30.901Z",
  "data": {
    "vaultAccountId": "11223344-5566-7788-99aa-bbccddeeff00",
    "assetId": "c1d2e3f4-a5b6-7890-cdef-123456789abc"
  }
}

Почему так мало?

Балансы меняются часто, и встраивать их прямо в событие — race-condition. Считайте balance.updated сигналом инвалидации кэша: получили — запросите свежий баланс.


Гарантии доставки

  • At-least-once. Сетевые сбои, кратковременные 5xx у вас или падение воркера могут привести к повторной доставке одного события. Сделайте обработчик идемпотентным (см. ниже).
  • Порядок внутри одного entityId соблюдается best-effort, но не гарантирован. Если порядок важен — сравнивайте previousStatusstatus и timestamp.
  • Повторы: неуспешные доставки (всё, что не 2xx, и сетевые ошибки) повторяются с задержкой примерно 10 с, 30 с, 60 с, 5 мин, 15 мин. После 5 неуспешных попыток доставка помечается dead и видна в GET /webhooks/deliveries.
  • Таймаут: каждой попытке HTTP отводится 5 секунд. Медленные обработчики приводят к повторам.

Рекомендации

Отвечайте быстро, обрабатывайте асинхронно

Возвращайте 2xx за секунду. Реальную обработку выносите в очередь или фоновую задачу. Медленный обработчик = таймауты и повторы = вы обработаете одно событие несколько раз.

Идемпотентность: дедуп по (eventType, entityId, status)

Одно и то же событие может прийти повторно. При первой обработке сохраняйте запись с ключом из payload-а (или из (eventType, entityId, data.status) для транзакций); при повторе — пропускайте.

Проверяйте подпись на каждом запросе

Если не проверять — любой, угадавший URL, может присылать вам поддельные события.

Перечитывайте состояние с API

После transaction.status.updated → Completed и на balance.updated запрашивайте актуальное состояние с API (/wallets/{id}/balance, /transactions/{id}). Payload вебхука — это снимок; источник истины — API.

Подписывайтесь только на нужное

Сужайте subscribedEventTypes до тех, что действительно использует ваш код.


Просмотр доставок

GET /webhooks/deliveries — последние попытки доставки и их статусы (pending, delivered, failed, dead). Полезно при отладке.

GET /webhooks/deliveries/{id} — полная карточка одной доставки: исходный payload, каждая попытка с HTTP-статусом, телом ответа, текстом ошибки и длительностью. Используйте, чтобы разобраться, почему failed или dead доставка не дошла до вашего эндпоинта.

{
  "id": "9f8e7d6c-5b4a-3210-9876-543210fedcba",
  "eventType": "transaction.status.updated",
  "entityType": "transaction",
  "entityId": "b6c7d8e9-f0a1-2345-bcde-6789abcdef01",
  "payload": "{\"eventType\":\"transaction.status.updated\", ...}",
  "status": "failed",
  "attemptCount": 3,
  "lastAttemptAt": "2026-04-26T18:51:30.118Z",
  "lastError": "HTTP 503 от вашего эндпоинта",
  "createdAt": "2026-04-26T18:46:30.118Z",
  "deliveredAt": null,
  "attempts": [
    {
      "id": "...",
      "attemptNumber": 1,
      "requestUrl": "https://your-backend.example.com/webhooks/custody",
      "httpStatusCode": 503,
      "responseBody": "service unavailable",
      "errorMessage": null,
      "durationMs": 412,
      "attemptedAt": "2026-04-26T18:46:30.530Z",
      "success": false
    }
  ]
}

POST /webhooks/deliveries/{id}/retry — ручной повтор failed или dead доставки.

GET /webhooks/events/catalog — актуальный список всех типов событий с описаниями.