Вебхуки криптоплатежей: проверка, ретраи и идемпотентность
Вебхуки — способ, которым ваш бэкенд узнаёт о поступлении платежа. Именно здесь чаще всего возникают ошибки интеграции. Этот гайд описывает, как ChainPay их доставляет и как безопасно обрабатывать.
Что вы получаете
ChainPay отправляет POST с JSON на ваш callbackUrl. После перевода в TRON payload выглядит так:
{
"status": "confirmed",
"externalReference": "order-123",
"chain": "tron",
"tokenSymbol": "USDT",
"tokenAddress": "T…",
"isNative": false,
"amount": "25.000000",
"txHash": "802999e236cf8f…",
"fee": "1.25",
"valueForwarded": "23.75",
"txidOut": "b1c2…"
}
status—pending(только если вы включили ранние уведомления черезpending: true) илиconfirmed.tokenSymbol,tokenAddressиisNativeописывают актив, который действительно поступил; это важно для мультитокенных адресов.fee,valueForwardedиtxidOut(транзакция выплаты) присутствуют только тогда, когда результат перевода известен на момент уведомления — сейчас это TRON. В EVM-сетяхconfirmed-вебхук отправляется при достижении порога подтверждений и этих полей не содержит.
1. Проверяйте отправителя
Вебхуки не подписываются криптографически. Добавьте длинный случайный секрет в query-строку callbackUrl и сравнивайте его за постоянное время, прежде чем делать что-либо ещё. Никогда не доверяйте amount или externalReference из неаутентифицированного запроса.
2. Быстро отвечайте 2xx
Возвращайте 200 сразу после сохранения события. ChainPay ждёт ответ 10 секунд, любой не-2xx статус или тайм-аут считает неудачей и не следует за редиректами. Медленную работу (письма, выполнение заказа) выносите в очередь.
3. Ожидайте ретраи и дубликаты
Неудачная доставка повторяется до 8 раз с экспоненциальной задержкой: от 30 секунд с ограничением в час. Доставка — минимум один раз, поэтому одно и то же событие может прийти несколько раз. Используйте txHash вместе со status как ключ идемпотентности и добавьте на него уникальный индекс в базе.
import express from 'express';
import crypto from 'node:crypto';
const app = express();
app.use(express.json());
const SECRET = process.env.WEBHOOK_SECRET;
const processed = new Set(); // use a database unique index in production
app.post('/crypto-webhook', async (req, res) => {
const given = Buffer.from(String(req.query.token ?? ''));
const expected = Buffer.from(SECRET);
if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) {
return res.sendStatus(403);
}
const { status, externalReference, amount, txHash } = req.body;
const key = `${txHash}:${status}`;
if (processed.has(key)) return res.sendStatus(200); // duplicate delivery
if (status === 'confirmed') {
await creditOrder(externalReference, amount); // your logic
}
processed.add(key);
res.sendStatus(200); // any non-2xx makes ChainPay retry
});
app.listen(8080);
4. Зачисляйте только при confirmed
pending означает, что перевод виден в блокчейне, но его ещё могут отменить реорганизацией. Показывайте его в интерфейсе, если хотите, но отдавайте товар только при confirmed.
5. Сверяйтесь через status API
Вебхук можно пропустить, если сервер был недоступен дольше окна ретраев. Считайте status API источником истины и периодически проверяйте заказы, которые остаются неоплаченными:
curl https://api.chainpay.site/deposit-address/ADDRESS_ID/status
curl https://api.chainpay.site/deposit-address/ADDRESS_ID/transactions
6. Проверяйте сумму
Плательщик может отправить меньше или больше запрошенного. Сравнивайте amount с суммой заказа, определите поведение для недоплат и переплат и никогда не предполагайте, что суммы совпадают.
Требования к callback URL
callbackUrl должен быть публичным HTTP(S)-адресом. Localhost и приватные IP отклоняются при создании адреса и снова при каждой доставке.