Обзор
Система webhook-уведомлений Meridian обеспечивает надежную доставку событий об изменении статуса инвойсов на ваш endpoint.
События webhook
Типы событий
Webhook отправляются при следующих изменениях статуса инвойса:
| Триггер | Когда отправляется |
|---|
| Инвойс оплачен | После успешного подтверждения платежа и распределения средств |
| Истек срок | После истечения 10 минут без оплаты |
| Отменен | При ручной отмене мерчантом или администратором |
| Отправлен на проверку | Трейдер отправил пруф оплаты на проверку (для OUT инвойсов) |
| Создан спор | Открыт спор по инвойсу |
| Спор решен | Спор закрыт с решением |
Формат webhook запроса
HTTP запрос
POST {notificationUrl}
Content-Type: application/json
X-Webhook-Signature: {hmac_sha256_signature}
X-Webhook-Event: {event_type}
X-Webhook-Delivery-Id: {unique_delivery_id}
Заголовки
HMAC-SHA256 подпись тела запроса в hex-форматеИспользуйте для проверки подлинности webhook (см. раздел Верификация подписи)
Тип события (например, invoice.paid, invoice.expired)Позволяет быстро определить тип события без парсинга тела
Уникальный ID доставки webhook (UUID)Используйте для идемпотентности - сохраняйте обработанные ID, чтобы не обрабатывать дубликаты
Retry стратегия
Автоматические повторы
Meridian автоматически повторяет неудачные доставки webhook:
| Попытка | Задержка | Условие |
|---|
| 1 | Немедленно | Первая попытка доставки |
| 2 | ~1 минута | Если первая попытка вернула не-2xx или timeout |
| 3 | ~5 минут | Если вторая попытка вернула не-2xx или timeout |
После 3 неудачных попыток webhook перемещается в Dead Letter Queue (DLQ) для ручной проверки.
Важно: Webhook считается успешным только при ответе 200-299. Любой другой код (включая 3xx, 4xx, 5xx) вызовет retry.
Когда происходит retry
| Сценарий | Retry? | Примечания |
|---|
| HTTP 200-299 | ❌ Нет | Успех |
| HTTP 4xx | ✅ Да | Ваш endpoint может быть временно недоступен |
| HTTP 5xx | ✅ Да | Ошибка сервера |
| Timeout (>10s) | ✅ Да | Сетевая проблема |
| Connection refused | ✅ Да | Endpoint недоступен |
| DNS failure | ✅ Да | Проблемы DNS |
Тело webhook запроса
Для событий инвойса
Для событий тело запроса содержит полный объект инвойса c новым статусом:
Пример для direction: "in" (входящий платеж):
{
"id": "cm3k8x7y80001z8j4k5m6n7o8",
"amount": "1000.0000",
"status": "paid",
"currency": "RUB",
"dealRate": "95.50",
"expireAt": "2025-11-03T15:10:00+03:00",
"traderId": "b95ce065-6ebc-426d-b",
"createdAt": "2025-11-03T15:00:00+03:00",
"direction": "in",
"updatedAt": "2025-11-03T15:05:00+03:00",
"requisiteId": "req_abc123xyz789",
"paymentMethod": "SBP",
"paymentOption": "sberbank",
"dealRequisites": "{\"bankName\":\"sberbank\",\"fullName\":\"Иван Иванов\",\"phoneNumber\":\"79001234567\"}",
"internalId": "order-12345",
"merchantName": "MerchantName"
}
Пример для direction: "out" (исходящий платеж) со статусом paid:
{
"id": "cm3k8x7y80001z8j4k5m6n7o8",
"amount": "1000.0000",
"status": "paid",
"currency": "RUB",
"dealRate": "95.50",
"newStatusExpiresAt": "2025-11-03T20:00:00+03:00",
"traderId": "b95ce065-6ebc-426d-b",
"createdAt": "2025-11-03T15:00:00+03:00",
"direction": "out",
"updatedAt": "2025-11-03T15:05:00+03:00",
"paymentMethod": "TO_CARD",
"paymentOption": "sberbank",
"dealRequisites": {
"fullName": "Иван Иванов",
"cardNumber": "1234567890123456"
},
"transactionProofUrl": "https://s3.amazonaws.com/presigned-url-with-expiry",
"internalId": "payout-67890",
"merchantName": "MerchantName"
}
Поле newStatusExpiresAt: Для OUT инвойсов указывает время истечения заявки в статусе "new" (5 часов с момента создания). После истечения средства возвращаются на баланс мерчанта.Поле transactionProofUrl: Для OUT инвойсов в статусах review или paid включает presigned S3 URL (время действия 7 дней) для доступа к доказательству оплаты, загруженному трейдером.
Для событий спора
Для событий тело запроса содержит полный объект аппеляции с новым статусом:
{
"id": "disp_abc123xyz789",
"invoiceId": "cm3k8x7y80001z8j4k5m6n7o8",
"reason": "invalid_sum",
"description": "Клиент отправил 500 RUB вместо 1000 RUB",
"disputeReasonData": {
"amount": 500
},
"attachmentUrl": "https://meridian-disputes.s3.amazonaws.com/disp_abc123xyz789/screenshot.jpg",
"attachmentFilename": "screenshot.jpg",
"status": "open",
"resolution": null,
"resolutionNotes": null,
"createdAt": "2025-11-03T15:10:00+03:00",
"resolvedAt": null,
"autoResolveAt": "2025-11-03T16:10:00+03:00",
"amount": "1000.0000",
"currency": "RUB",
"paymentMethod": "SBP",
"paymentOption": "sberbank",
"requisites": {
"bankName": "sberbank",
"phoneNumber": "79099966512",
"fullName": "Ivan Ivanov"
},
"internalId": "order-12345",
"merchantName": "MerchantName"
}
Верификация подписи
Алгоритм подписи
Meridian подписывает каждый webhook запрос используя HMAC-SHA256 для обеспечения подлинности и целостности данных.
Параметры алгоритма:
- Алгоритм: HMAC-SHA256
- Секретный ключ: Ваш
notificationToken (32-255 символов)
- Входные данные: JSON тело запроса (без модификаций)
- Выходной формат: Hex-кодированная строка
- Заголовок:
X-Webhook-Signature
Процесс генерации подписи:
1. Получить JSON тело webhook (как строку)
2. Создать HMAC с алгоритмом SHA256 и секретным ключом notificationToken
3. Обновить HMAC данными из тела
4. Вычислить digest в hex-формате
5. Отправить в заголовке X-Webhook-Signature
Критически важно: Используйте точное JSON тело запроса для верификации без изменений форматирования, пробелов или порядка ключей. Любое изменение приведет к несовпадению подписи.
Примеры кода
const crypto = require('crypto');
const express = require('express');
const app = express();
// ВАЖНО: Используйте raw body parser для webhook endpoint
app.use('/webhooks/meridian', express.json({
verify: (req, res, buf) => {
// Сохраняем raw body для верификации подписи
req.rawBody = buf.toString('utf8');
}
}));
app.post('/webhooks/meridian', (req, res) => {
const signature = req.headers['x-webhook-signature'];
const event = req.headers['x-webhook-event'];
const deliveryId = req.headers['x-webhook-delivery-id'];
// Ваш notificationToken из настроек инвойса
const notificationToken = process.env.MERIDIAN_NOTIFICATION_TOKEN;
// Проверка подписи
if (!verifySignature(req.rawBody, signature, notificationToken)) {
console.error('Invalid webhook signature');
return res.status(401).send('Invalid signature');
}
// Обработка события
const payload = req.body;
console.log(`Received ${event} for invoice ${payload.id}`);
// Идемпотентность: проверьте deliveryId перед обработкой
if (isAlreadyProcessed(deliveryId)) {
return res.status(200).send('Already processed');
}
// Обработка по типу события
switch (event) {
case 'invoice.paid':
handleInvoicePaid(payload);
break;
case 'invoice.expired':
handleInvoiceExpired(payload);
break;
// ... другие события
}
// Важно: всегда возвращайте 200 для успешной обработки
res.status(200).send('OK');
});
function verifySignature(payload, signature, secret) {
const hmac = crypto.createHmac('sha256', secret);
hmac.update(payload);
const expectedSignature = hmac.digest('hex');
// Используйте timing-safe сравнение для защиты от timing attacks
try {
return crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expectedSignature, 'hex')
);
} catch (err) {
// Невалидный hex или разная длина
return false;
}
}
function isAlreadyProcessed(deliveryId) {
// Реализуйте проверку в вашей БД или кеше
// Например: return db.webhookDeliveries.exists(deliveryId);
return false;
}
function handleInvoicePaid(invoice) {
console.log(`Invoice ${invoice.id} paid: ${invoice.amount} ${invoice.currency}`);
// Выполните бизнес-логику: отметьте заказ как оплаченный, отправьте товар и т.д.
}
function handleInvoiceExpired(invoice) {
console.log(`Invoice ${invoice.id} expired`);
// Отметьте заказ как истекший
}
app.listen(3000, () => {
console.log('Webhook server listening on port 3000');
});