Skip to main content

Обзор

Система 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}

Заголовки

X-Webhook-Signature
string
HMAC-SHA256 подпись тела запроса в hex-форматеИспользуйте для проверки подлинности webhook (см. раздел Верификация подписи)
X-Webhook-Event
string
Тип события (например, invoice.paid, invoice.expired)Позволяет быстро определить тип события без парсинга тела
X-Webhook-Delivery-Id
string
Уникальный 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');
});