Вебхуки¶
Платформа доставляет уведомления о событиях вашему бэкенду через исходящие
вебхуки. Когда происходит важное событие — создана транзакция, выпущен
кошелёк, изменился баланс — платформа отправляет HTTP POST на указанный
вами URL, подписанный HMAC-SHA256.
В этом руководстве: настройка, проверка подписи, каталог событий со схемами payload-а, гарантии доставки и рекомендации.
Как это работает¶
Ваш бэкенд Платформа Outbox-воркер
| | |
|-- PUT /webhooks/outbound| |
| url + секрет подписи | |
| | |
| (произошло событие) |
| |-- запись в outbox -------->|
| | |
| | (раз в ~5 с) |
|<-- POST {payload}, X-Webhook-Signature -------------|
|-- 2xx ---------------->| |
- Регистрируете HTTPS-эндпоинт через
PUT /webhooks/outbound. - Платформа возвращает секрет подписи HMAC-SHA256. Сохраните его.
- События персистятся в надёжный outbox.
- Воркер доставляет события на ваш URL с гарантией at-least-once. Неудачные попытки повторяются с экспоненциальной задержкой.
- Ваш эндпоинт проверяет подпись, обрабатывает событие и быстро отвечает
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. Тестовая доставка¶
Платформа отправит синтетическое событие webhook.test на ваш URL по тому
же подписному пути, что и реальные события.
4. Получить текущую конфигурацию¶
Узнать настроенный URL и список подписанных событий вашего тенанта:
Ответ:
{
"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' — зачисляйте клиенту; Failed (с failureReason) — возвращайте/уведомляйте. |
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, но не гарантирован. Если порядок важен — сравнивайтеpreviousStatus→statusи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 — актуальный список всех типов событий с
описаниями.