Skip to main content
При каждом изменении статуса депозита или выплаты платформа отправляет POST на callback_url, настроенный для вашего сайта. Это основной и самый надёжный способ узнавать об оплате — webhook приходит сразу после изменения, без задержек polling’а.
Webhook’и идут в одну сторону: от платформы к вашему серверу. Запросы к Public API (создание депозитов, выплат и т. д.) подписываются по-другому — см. Аутентификация. Здесь описана проверка входящих к вам webhook’ов, которые платформа подписывает ключом callback_secret вашего сайта.

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

1

Событие происходит

Депозит финализируется, выплата уходит в сеть и т. п. — статус меняется на стороне платформы.
2

Платформа формирует и подписывает webhook

Тело сериализуется в JSON, подписывается HMAC-SHA256 по callback_secret сайта, добавляются заголовки X-Signature / X-Timestamp / X-Event-Type / X-Event-Id.
3

Доставка на ваш callback_url

POST на ваш endpoint. Успех — ответ HTTP 2xx в течение 10 секунд.
4

Вы проверяете подпись и обрабатываете

Сверьте X-Signature constant-time, дедуплицируйте по X-Event-Id, ответьте 200.

Заголовки доставки

Каждый webhook приходит со следующими заголовками (имена регистронезависимы):
ЗаголовокЗначение
X-SignatureHMAC-SHA256 в нижнем регистре hex от сырого тела запроса, ключ — callback_secret сайта
X-TimestampUnix-время отправки в секундах (момент попытки доставки)
X-Event-Typeтип события — deposit.* либо payout.* (см. События)
X-Event-IdUUID логического события — для идемпотентности (один и тот же id на все повторы)
Content-Typeapplication/json
Всегда проверяйте X-Signature до обработки тела. Посчитайте HMAC-SHA256 от сырых байтов запроса с вашим callback_secret и сравните constant-time. При несовпадении — отклоняйте запрос (401) и не выполняйте бизнес-логику. Подпись считается именно от сырого тела — не от распарсенного и заново сериализованного JSON (порядок ключей и пробелы изменят результат).

Об X-Event-Id и идемпотентности

X-Event-Id детерминирован: один логический event (тот же ресурс + тот же тип события) всегда получает один и тот же id — стабильный между повторными попытками и даже между перезапусками платформы. Значение в заголовке X-Event-Id совпадает с полем eventId в теле. Используйте его как ключ дедупликации: если событие с этим id уже обработано — ответьте 200 и не выполняйте побочных эффектов повторно.

События

X-Event-Type (и поле eventType в теле) принимает значения:
X-Event-TypeКогда отправляется
deposit.tx_detectedзамечена первая входящая транзакция на адрес (ещё не подтверждена)
deposit.finalizedдепозит финализирован — статус paid, paid_over или wrong_amount
deposit.failedдепозит не удался — fail / system_fail / истёк срок
deposit.refundedотправлен возврат (refund_paid)
payout.broadcastedвыплата отправлена в сеть
payout.confirmedвыплата подтверждена в сети
payout.failedвыплата не удалась
Финальный статус депозита смотрите в поле deposit.status тела события (например, deposit.finalized может нести status: "paid", "paid_over" или "wrong_amount"). Подробнее о статусах — на странице Депозиты и Выплаты. Тестовый webhook из эндпоинта /test приходит с особым X-Event-Type: webhook.test — это не боевое событие.

Тело webhook’а

Тело — JSON c полями eventType, eventId и вложенным объектом deposit либо payout (в зависимости от ресурса). Поле eventId дублирует заголовок X-Event-Id.
{
  "eventType": "deposit.finalized",
  "eventId": "3f1b2c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
  "deposit": {
    "uuid": "8f3a1c2e-5b6d-4e7f-9a0b-1c2d3e4f5a6b",
    "orderId": "order-2026-000042",
    "status": "paid",
    "assetCode": "USDT_TRC20",
    "expectedAmount": "100.50",
    "receivedAmount": "100.50",
    "address": "TKh9wq8c4dZsL1mP2nQ3rS4tU5vW6xY7zA",
    "memo": null,
    "txHash": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2",
    "confirmations": 19,
    "requiredConfirmations": 19,
    "finalizedAt": "2026-06-02T20:41:05.000Z"
  }
}

Поля объекта deposit

uuid
string
UUID депозитной транзакции на платформе.
orderId
string | null
Ваш order_id, переданный при создании депозита (ключ идемпотентности на стороне сайта).
status
string
Статус депозита: paid, paid_over, wrong_amount, fail, system_fail, refund_paid и др.
assetCode
string
Код актива, например USDT_TRC20, USDT_TON, TRX, TON.
expectedAmount
string
Ожидаемая сумма (строка — без потери точности).
receivedAmount
string | null
Фактически полученная сумма (строка). null, если приход ещё не зафиксирован.
address
string
Адрес назначения депозита.
memo
string | null
Memo/comment для memo-based сетей (TON). null для account-based сетей (TRON).
txHash
string | null
Хеш входящей транзакции. null, если транзакция ещё не замечена.
confirmations
number
Текущее число подтверждений.
requiredConfirmations
number
Сколько подтверждений требуется для финализации.
finalizedAt
string
ISO-8601 момент изменения статуса.

Поля объекта payout

uuid
string
UUID выплаты на платформе.
orderId
string | null
Ваш order_id, переданный при создании выплаты.
status
string
Статус выплаты: broadcasted, confirmed, failed и др.
assetCode
string
Код актива выплаты.
amount
string
Сумма выплаты (строка, отформатирована до decimals актива).
destinationAddress
string
Адрес получателя.
destinationMemo
string | null
Memo/comment получателя для memo-based сетей. null для account-based.
txHash
string | null
Реальный on-chain хеш транзакции (для TON резолвится мониторингом с задержкой). null, пока не отправлено.
explorerTxUrl
string | null
Готовая ссылка на транзакцию в обозревателе сети. null, если ещё недоступна.
confirmations
number | null
Текущее число подтверждений. null, пока нет on-chain транзакции.
requiredConfirmations
number | null
Требуемое число подтверждений. null, пока нет on-chain транзакции.
failReason
string | null
Причина ошибки для payout.failed. null для успешных событий.
updatedAt
string
ISO-8601 момент изменения статуса.

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

Сверяйте X-Signature с HMAC-SHA256 от сырых байтов запроса. Прочитайте тело как буфер до парсинга JSON — иначе повторная сериализация изменит подпись.
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const CALLBACK_SECRET = process.env.CALLBACK_SECRET;

// ВАЖНО: express.raw — получаем сырой Buffer, а не распарсенный объект.
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const raw = req.body;                       // Buffer — сырые байты тела
  const sig = req.header('X-Signature') ?? '';

  const expected = crypto
    .createHmac('sha256', CALLBACK_SECRET)
    .update(raw)
    .digest('hex');

  // constant-time сравнение; длины должны совпадать
  const a = Buffer.from(expected);
  const b = Buffer.from(sig);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(401).end();             // подпись не совпала — отклоняем
  }

  const event = JSON.parse(raw.toString('utf8'));

  // Идемпотентность: если event.eventId уже обработан — выходим без побочных эффектов.
  if (alreadyProcessed(event.eventId)) {
    return res.status(200).end();
  }

  // ... ваша бизнес-логика по event.eventType / event.deposit / event.payout ...
  markProcessed(event.eventId);

  res.status(200).end();                       // 2xx в течение 10с = доставлено
});
callback_secret выдаётся при настройке сайта и доступен только владельцу инстанса. Он никогда не передаётся в открытом виде по сети. Если подозреваете компрометацию — ротируйте секрет в админ-кабинете; после ротации проверяйте подпись новым значением.

Политика повторных попыток

Доставка считается успешной, если ваш endpoint вернул HTTP 2xx в течение 10 секунд (тайм-аут настраивается оператором в диапазоне 1–60 секунд). Любой другой исход — не-2xx ответ, тайм-аут или сетевая ошибка — считается неудачей, и платформа повторяет доставку по фиксированному расписанию с возрастающими интервалами:
1

Попытка 2 — через 30 секунд

2

Попытка 3 — через 2 минуты

3

Попытка 4 — через 10 минут

4

Попытка 5 — через 1 час

5

Попытка 6 — через 6 часов

6

Попытка 7+ — через 24 часа (далее интервал не растёт)

Максимум 8 попыток по умолчанию (оператор может настроить от 1 до 20). После исчерпания попыток доставка помечается как fail; такой webhook можно вручную переотправить через эндпоинт resend.
Один и тот же X-Event-Id приходит на все попытки доставки одного события. Обработайте событие ровно один раз: при повторе с уже виденным id отвечайте 200 без побочных эффектов. Иначе временный сбой на вашей стороне приведёт к двойной обработке оплаты.
Если для сайта включён режим allow_private_hosts, callback_url может указывать на адрес во внутренней сети (например, CMS обменника на том же сервере). По умолчанию внутренние/зарезервированные адреса отклоняются SSRF-защитой, и такие попытки логируются как неудачные.

Тест и переотправка

Два служебных эндпоинта Public API помогают отладить интеграцию. Оба требуют стандартной HMAC-аутентификации Public API (заголовки X-Api-Id / X-Timestamp / X-Signature) и возвращают результат в общем конверте ответа { "ok": true, "data": ... }.
ЭндпоинтДействие
POST /v1/public/webhooks/testПоставить в очередь один тестовый подписанный webhook на ваш callback_url (без повторов).
POST /v1/public/webhooks/resend/{eventId}Переотправить ранее сгенерированный webhook по его X-Event-Id (с обычным расписанием повторов).

POST /v1/public/webhooks/test

Ставит в очередь одну попытку доставки тестового webhook’а с теми же заголовками и подписью, что и у боевых событий, но с особым типом X-Event-Type: webhook.test. Удобно проверить, что ваш endpoint доступен, принимает POST и корректно валидирует X-Signature. Повторов нет — ровно одна попытка. Тело тестового webhook’а, которое получит ваш callback_url:
Тело тестового webhook
{
  "event": "webhook.test",
  "message": "Test webhook from WalletCore",
  "site": "8f3a1c2e-5b6d-4e7f-9a0b-1c2d3e4f5a6b",
  "timestamp": 1780000000
}
curl -X POST https://wallet.your-exchange.com/v1/public/webhooks/test \
  -H "X-Api-Id: pk_live_a1b2c3d4" \
  -H "X-Timestamp: 1780000000" \
  -H "X-Signature: 9f1c0b8a7d6e5f4c3b2a1908f7e6d5c4b3a2918f0e7d6c5b4a392817f6e5d4c3"
{
  "ok": true,
  "data": {
    "queued": true,
    "url": "https://shop.example.com/webhook",
    "eventId": "1c9b8a7d-6e5f-4c3b-2a19-08f7e6d5c4b3"
  }
}
Поля data:
ПолеТипОписание
queuedbooleantrue — тестовый webhook поставлен в очередь доставки.
urlstringcallback_url, на который будет доставлен тест.
eventIdstringUUID этого тестового события (придёт в X-Event-Id).

POST /v1/public/webhooks/resend/{eventId}

Переотправляет ранее сгенерированный webhook по его X-Event-Id. Тело и URL берутся из лога доставки, подпись пересчитывается под текущий callback_secret. Повтор использует обычное расписание повторных попыток. Переотправлять можно только события своего сайта — доступ к чужим eventId невозможен (вернётся 404).
curl -X POST \
  https://wallet.your-exchange.com/v1/public/webhooks/resend/3f1b2c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d \
  -H "X-Api-Id: pk_live_a1b2c3d4" \
  -H "X-Timestamp: 1780000000" \
  -H "X-Signature: 7d6c5b4a392817f6e5d4c3b2a1908f7e6d5c4b3a2918f0e7d6c5b4a392817f6e5"
{
  "ok": true,
  "data": {
    "enqueued": true,
    "eventId": "3f1b2c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
  }
}
Поля data:
ПолеТипОписание
enqueuedbooleantrue — переотправка поставлена в очередь.
eventIdstringX-Event-Id переотправляемого события.

Частые вопросы

Почти всегда причина в том, что подпись считается не от сырого тела. Прочитайте тело как буфер/строку до парсинга JSON и считайте HMAC-SHA256 именно от этих байтов. Повторная сериализация распарсенного объекта меняет порядок ключей и пробелы — подпись не совпадёт. В Express используйте express.raw(), во Flask — request.get_data().
Проверьте по порядку: настроен ли callback_url для сайта; доступен ли ваш endpoint извне (или включён ли allow_private_hosts, если он во внутренней сети); не режут ли запрос ваш firewall/WAF. Затем вызовите POST /v1/public/webhooks/test — он поставит тестовую доставку и вернёт url и eventId, по которым видно, куда платформа пыталась доставить.
Это ожидаемо: при не-2xx ответе или тайм-ауте платформа повторяет доставку (до 8 раз по умолчанию), а одно событие может также прийти повторно после ручного resend. На все повторы один и тот же X-Event-Id. Дедуплицируйте по нему: при уже обработанном id отвечайте 200 без побочных эффектов.
10 секунд по умолчанию (оператор может задать 1–60 секунд). Если обработка дольше — примите webhook, поставьте задачу в свою очередь и сразу верните 200, а тяжёлую логику выполняйте асинхронно. Долгий ответ платформа считает неудачей и повторит доставку.
deposit.tx_detected — первая входящая транзакция замечена, но ещё не набрала нужных подтверждений (receivedAmount/txHash могут быть уже заполнены, но статус не финальный). deposit.finalized — депозит достиг requiredConfirmations; смотрите deposit.status (paid / paid_over / wrong_amount), чтобы понять исход. Зачислять средства клиенту стоит на deposit.finalized со статусом paid/paid_over, не на tx_detected.
Указанный eventId не найден среди событий вашего сайта. Переотправлять можно только свои события. Проверьте, что eventId взят из реально доставлявшегося ранее webhook’а (заголовок X-Event-Id или поле eventId тела) и принадлежит тому же сайту, чьим ключом подписан запрос.

Смежные страницы