Разработка callback/webhook уведомлений о платежах
Платёж зафиксирован в блокчейне — значит, его нужно обработать в вашей системе. Проблема: блокчейн не умеет "звонить" в ваш бэкенд. Есть два подхода: polling (вы опрашиваете) и push (внешняя система уведомляет вас). Webhook — это push-уведомление от платёжного процессора или вашего собственного мониторинга на блокчейн.
Архитектура webhook-системы
Типичная схема для крипто-платежей:
Blockchain → Monitoring service → Webhook dispatcher → Your app endpoint
↓
Retry queue (Redis/DB)
Monitoring service — это либо сторонний провайдер (Alchemy Notify, Moralis Streams, QuickNode Streams), либо ваш собственный процесс, слушающий ноду.
Получение webhook от Alchemy Notify
// Создание webhook через Alchemy API
const response = await fetch('https://dashboard.alchemy.com/api/create-webhook', {
method: 'POST',
headers: {
'X-Alchemy-Token': process.env.ALCHEMY_AUTH_TOKEN!,
'Content-Type': 'application/json',
},
body: JSON.stringify({
network: 'ETH_MAINNET',
webhook_type: 'ADDRESS_ACTIVITY',
webhook_url: 'https://yourapp.com/webhooks/crypto',
addresses: ['0xYourAddress'],
}),
})
Ваш webhook endpoint: правильная обработка
Главное правило: webhook endpoint должен отвечать 200 как можно быстрее. Всю тяжёлую логику — в очередь:
// Express.js endpoint
app.post('/webhooks/crypto', express.raw({ type: 'application/json' }), async (req, res) => {
// 1. Верифицировать подпись — ДО любой обработки
const signature = req.headers['x-alchemy-signature'] as string
const isValid = verifyAlchemySignature(req.body, signature, process.env.WEBHOOK_SIGNING_KEY!)
if (!isValid) {
return res.status(401).send('Invalid signature')
}
// 2. Ответить 200 немедленно
res.status(200).send('OK')
// 3. Поставить в очередь для асинхронной обработки
const payload = JSON.parse(req.body.toString())
await jobQueue.add('process-crypto-payment', payload, {
attempts: 5,
backoff: { type: 'exponential', delay: 2000 },
})
})
function verifyAlchemySignature(body: Buffer, signature: string, signingKey: string): boolean {
const hmac = crypto.createHmac('sha256', signingKey)
hmac.update(body)
const digest = hmac.digest('hex')
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(digest))
}
timingSafeEqual обязателен — без него timing attack позволит подобрать подпись за разумное время.
Идемпотентность: обрабатывать повторы корректно
Webhook-провайдеры гарантируют доставку at-least-once, не exactly-once. Ваш обработчик обязан быть идемпотентным:
async function processPaymentWebhook(txHash: string, address: string, amountWei: bigint) {
// Используем INSERT ... ON CONFLICT DO NOTHING
const result = await db.query(`
INSERT INTO processed_webhooks (tx_hash, processed_at)
VALUES ($1, NOW())
ON CONFLICT (tx_hash) DO NOTHING
RETURNING id
`, [txHash])
if (result.rowCount === 0) {
// Уже обработано — пропускаем, не ошибка
return
}
// Основная логика обработки платежа
await updatePaymentStatus(address, amountWei, txHash)
}
Retry-механизм для исходящих webhook (если ваш сервис нотифицирует другие)
Если ваш сервис сам отправляет webhook в системы клиентов:
interface WebhookDelivery {
id: string
url: string
payload: object
attempt: number
nextRetryAt: Date
}
async function deliverWebhook(delivery: WebhookDelivery): Promise<void> {
try {
const res = await fetch(delivery.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': signPayload(delivery.payload),
'X-Webhook-ID': delivery.id,
'X-Webhook-Attempt': String(delivery.attempt),
},
body: JSON.stringify(delivery.payload),
signal: AbortSignal.timeout(10_000), // 10 секунд таймаут
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
await db.markDelivered(delivery.id)
} catch (err) {
const nextAttempt = delivery.attempt + 1
if (nextAttempt > 10) {
await db.markFailed(delivery.id, String(err))
return
}
// Экспоненциальный backoff: 30s, 1m, 2m, 5m, 10m, ...
const delayMs = Math.min(30_000 * Math.pow(2, nextAttempt - 1), 3_600_000)
await db.scheduleRetry(delivery.id, nextAttempt, new Date(Date.now() + delayMs))
}
}
Схема retry: 10 попыток с экспоненциальным backoff достаточно для большинства случаев. Финальный провал — уведомить разработчиков, сохранить в dead letter queue.
Таблица для хранения доставок
CREATE TABLE webhook_deliveries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_type VARCHAR(50) NOT NULL,
payload JSONB NOT NULL,
target_url TEXT NOT NULL,
status VARCHAR(20) DEFAULT 'pending',
attempt INTEGER DEFAULT 0,
next_retry_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
delivered_at TIMESTAMPTZ,
last_error TEXT
);
CREATE INDEX ON webhook_deliveries(status, next_retry_at)
WHERE status IN ('pending', 'retrying');
Partial index по статусам — воркер при пулинге читает только активные записи, не сканирует доставленные.
Безопасность: что обязательно
- HMAC-SHA256 подпись на каждый исходящий webhook с секретом клиента
-
Timestamp в payload (поле
sent_at) и проверка на стороне получателя, что webhook не старше 5 минут — защита от replay attacks - HTTPS only — не доставлять на HTTP endpoints
-
Идемпотентный ключ в заголовке (
X-Webhook-ID) — получатель может дедуплицировать
Сроки реализации: базовый webhook endpoint с верификацией подписи и очередью — 1 день. Полноценная система с retry, dead letter queue, dashboard для мониторинга доставок, управлением подписками — 2–3 дня.







