> ## Documentation Index
> Fetch the complete documentation index at: https://docs.meridian.vip/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook-уведомления

## Обзор

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

### Заголовки

<ResponseField name="X-Webhook-Signature" type="string">
  HMAC-SHA256 подпись тела запроса в hex-формате

  Используйте для проверки подлинности webhook (см. раздел [Верификация подписи](#верификация-подписи))
</ResponseField>

<ResponseField name="X-Webhook-Event" type="string">
  Тип события (например, `invoice.paid`, `invoice.expired`)

  Позволяет быстро определить тип события без парсинга тела
</ResponseField>

<ResponseField name="X-Webhook-Delivery-Id" type="string">
  Уникальный ID доставки webhook (UUID)

  Используйте для идемпотентности - сохраняйте обработанные ID, чтобы не обрабатывать дубликаты
</ResponseField>

***

## Retry стратегия

### Автоматические повторы

Meridian автоматически повторяет неудачные доставки webhook:

| Попытка | Задержка   | Условие                                        |
| ------- | ---------- | ---------------------------------------------- |
| 1       | Немедленно | Первая попытка доставки                        |
| 2       | \~1 минута | Если первая попытка вернула не-2xx или timeout |
| 3       | \~5 минут  | Если вторая попытка вернула не-2xx или timeout |

**После 3 неудачных попыток** webhook перемещается в Dead Letter Queue (DLQ) для ручной проверки.

<Warning>
  **Важно**: Webhook считается успешным только при ответе `200-299`. Любой другой код (включая `3xx`, `4xx`, `5xx`) вызовет retry.
</Warning>

### Когда происходит retry

| Сценарий           | Retry? | Примечания                                  |
| ------------------ | ------ | ------------------------------------------- |
| HTTP 200-299       | ❌ Нет  | Успех                                       |
| HTTP 4xx           | ✅ Да   | Ваш endpoint может быть временно недоступен |
| HTTP 5xx           | ✅ Да   | Ошибка сервера                              |
| Timeout (>10s)     | ✅ Да   | Сетевая проблема                            |
| Connection refused | ✅ Да   | Endpoint недоступен                         |
| DNS failure        | ✅ Да   | Проблемы DNS                                |

***

## Тело webhook запроса

### Для событий инвойса

Для событий тело запроса содержит полный объект инвойса c новым статусом:

**Пример для `direction: "in"` (входящий платеж):**

```json theme={null}
{
  "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`:**

```json theme={null}
{
  "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"
}
```

<Info>
  **Поле `newStatusExpiresAt`**: Для OUT инвойсов указывает время истечения заявки в статусе `"new"` (5 часов с момента создания). После истечения средства возвращаются на баланс мерчанта.

  **Поле `transactionProofUrl`**: Для OUT инвойсов в статусах `review` или `paid` включает presigned S3 URL (время действия 7 дней) для доступа к доказательству оплаты, загруженному трейдером.
</Info>

### Для событий спора

Для событий тело запроса содержит полный объект аппеляции с новым статусом:

```json theme={null}
{
  "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
```

<Warning>
  **Критически важно**: Используйте точное JSON тело запроса для верификации без изменений форматирования, пробелов или порядка ключей. Любое изменение приведет к несовпадению подписи.
</Warning>

***

### Примеры кода

<CodeGroup>
  ```javascript Node.js theme={null}
  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');
  });
  ```

  ```python Python theme={null}
  import hmac
  import hashlib
  from flask import Flask, request, jsonify

  app = Flask(__name__)

  # Ваш notificationToken из настроек инвойса
  NOTIFICATION_TOKEN = 'your_notification_token_here'

  @app.route('/webhooks/meridian', methods=['POST'])
  def handle_webhook():
      signature = request.headers.get('X-Webhook-Signature')
      event = request.headers.get('X-Webhook-Event')
      delivery_id = request.headers.get('X-Webhook-Delivery-Id')

      # Получаем raw body для верификации
      payload = request.get_data(as_text=True)

      # Проверка подписи
      if not verify_signature(payload, signature, NOTIFICATION_TOKEN):
          print('Invalid webhook signature')
          return jsonify({'error': 'Invalid signature'}), 401

      # Парсим JSON
      data = request.get_json()
      print(f'Received {event} for invoice {data["id"]}')

      # Идемпотентность
      if is_already_processed(delivery_id):
          return jsonify({'status': 'already processed'}), 200

      # Обработка события
      if event == 'invoice.paid':
          handle_invoice_paid(data)
      elif event == 'invoice.expired':
          handle_invoice_expired(data)
      # ... другие события

      return jsonify({'status': 'ok'}), 200

  def verify_signature(payload, signature, secret):
      """
      Проверка HMAC-SHA256 подписи webhook
      """
      expected_signature = hmac.new(
          secret.encode('utf-8'),
          payload.encode('utf-8'),
          hashlib.sha256
      ).hexdigest()

      # Используйте timing-safe сравнение
      return hmac.compare_digest(signature, expected_signature)

  def is_already_processed(delivery_id):
      """
      Проверка, был ли этот webhook уже обработан
      Реализуйте проверку в вашей БД или кеше
      """
      # Например: return db.webhook_deliveries.exists(delivery_id)
      return False

  def handle_invoice_paid(invoice):
      print(f'Invoice {invoice["id"]} paid: {invoice["amount"]} {invoice["currency"]}')
      # Выполните бизнес-логику

  def handle_invoice_expired(invoice):
      print(f'Invoice {invoice["id"]} expired')
      # Обработка истекшего инвойса

  if __name__ == '__main__':
      app.run(port=3000)
  ```

  ```php PHP theme={null}
  <?php

  // Ваш notificationToken из настроек инвойса
  $notificationToken = getenv('MERIDIAN_NOTIFICATION_TOKEN');

  // Получаем заголовки
  $signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
  $event = $_SERVER['HTTP_X_WEBHOOK_EVENT'] ?? '';
  $deliveryId = $_SERVER['HTTP_X_WEBHOOK_DELIVERY_ID'] ?? '';

  // Получаем raw body
  $payload = file_get_contents('php://input');

  // Проверка подписи
  if (!verifySignature($payload, $signature, $notificationToken)) {
      error_log('Invalid webhook signature');
      http_response_code(401);
      echo json_encode(['error' => 'Invalid signature']);
      exit;
  }

  // Парсим JSON
  $data = json_decode($payload, true);
  error_log("Received {$event} for invoice {$data['id']}");

  // Идемпотентность
  if (isAlreadyProcessed($deliveryId)) {
      http_response_code(200);
      echo json_encode(['status' => 'already processed']);
      exit;
  }

  // Обработка события
  switch ($event) {
      case 'invoice.paid':
          handleInvoicePaid($data);
          break;
      case 'invoice.expired':
          handleInvoiceExpired($data);
          break;
      // ... другие события
  }

  // Возвращаем успех
  http_response_code(200);
  echo json_encode(['status' => 'ok']);

  function verifySignature($payload, $signature, $secret) {
      $expectedSignature = hash_hmac('sha256', $payload, $secret);

      // Используйте timing-safe сравнение
      return hash_equals($signature, $expectedSignature);
  }

  function isAlreadyProcessed($deliveryId) {
      // Реализуйте проверку в вашей БД
      // Например: return $db->query("SELECT EXISTS(SELECT 1 FROM webhook_deliveries WHERE id = ?)", [$deliveryId]);
      return false;
  }

  function handleInvoicePaid($invoice) {
      error_log("Invoice {$invoice['id']} paid: {$invoice['amount']} {$invoice['currency']}");
      // Выполните бизнес-логику
  }

  function handleInvoiceExpired($invoice) {
      error_log("Invoice {$invoice['id']} expired");
      // Обработка истекшего инвойса
  }
  ?>
  ```
</CodeGroup>
