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

# Webhooks

> Исходящие webhook'и: заголовки, проверка подписи, типы событий, политика повторов и эндпоинты test/resend

При каждом изменении статуса депозита или выплаты платформа отправляет `POST` на
`callback_url`, настроенный для вашего сайта. Это основной и самый надёжный способ
узнавать об оплате — webhook приходит сразу после изменения, без задержек polling'а.

<Note>
  Webhook'и идут в **одну сторону**: от платформы к вашему серверу. Запросы к Public API
  (создание депозитов, выплат и т. д.) подписываются по-другому — см.
  [Аутентификация](/authentication). Здесь описана проверка **входящих** к вам webhook'ов,
  которые платформа подписывает ключом `callback_secret` вашего сайта.
</Note>

## Как это работает

<Steps>
  <Step title="Событие происходит">
    Депозит финализируется, выплата уходит в сеть и т. п. — статус меняется на стороне платформы.
  </Step>

  <Step title="Платформа формирует и подписывает webhook">
    Тело сериализуется в JSON, подписывается `HMAC-SHA256` по `callback_secret` сайта,
    добавляются заголовки `X-Signature` / `X-Timestamp` / `X-Event-Type` / `X-Event-Id`.
  </Step>

  <Step title="Доставка на ваш callback_url">
    `POST` на ваш endpoint. Успех — ответ `HTTP 2xx` в течение 10 секунд.
  </Step>

  <Step title="Вы проверяете подпись и обрабатываете">
    Сверьте `X-Signature` constant-time, дедуплицируйте по `X-Event-Id`, ответьте `200`.
  </Step>
</Steps>

## Заголовки доставки

Каждый webhook приходит со следующими заголовками (имена регистронезависимы):

| Заголовок      | Значение                                                                                       |
| -------------- | ---------------------------------------------------------------------------------------------- |
| `X-Signature`  | `HMAC-SHA256` в нижнем регистре hex от **сырого тела** запроса, ключ — `callback_secret` сайта |
| `X-Timestamp`  | Unix-время отправки в секундах (момент попытки доставки)                                       |
| `X-Event-Type` | тип события — `deposit.*` либо `payout.*` (см. [События](#события))                            |
| `X-Event-Id`   | UUID логического события — **для идемпотентности** (один и тот же id на все повторы)           |
| `Content-Type` | `application/json`                                                                             |

<Warning>
  **Всегда проверяйте `X-Signature` до обработки тела.** Посчитайте `HMAC-SHA256` от
  сырых байтов запроса с вашим `callback_secret` и сравните constant-time. При несовпадении —
  отклоняйте запрос (`401`) и не выполняйте бизнес-логику. Подпись считается именно от
  **сырого тела** — не от распарсенного и заново сериализованного JSON (порядок ключей и
  пробелы изменят результат).
</Warning>

### Об X-Event-Id и идемпотентности

`X-Event-Id` детерминирован: один логический event (тот же ресурс + тот же тип события)
всегда получает **один и тот же** id — стабильный между повторными попытками и даже между
перезапусками платформы. Значение в заголовке `X-Event-Id` совпадает с полем `eventId` в теле.

Используйте его как ключ дедупликации: если событие с этим id уже обработано — ответьте
`200` и не выполняйте побочных эффектов повторно.

## События

`X-Event-Type` (и поле `eventType` в теле) принимает значения:

| `X-Event-Type`        | Когда отправляется                                                    |
| --------------------- | --------------------------------------------------------------------- |
| `deposit.tx_detected` | замечена первая входящая транзакция на адрес (ещё не подтверждена)    |
| `deposit.finalized`   | депозит финализирован — статус `paid`, `paid_over` или `wrong_amount` |
| `deposit.failed`      | депозит не удался — `fail` / `system_fail` / истёк срок               |
| `deposit.refunded`    | отправлен возврат (`refund_paid`)                                     |
| `payout.broadcasted`  | выплата отправлена в сеть                                             |
| `payout.confirmed`    | выплата подтверждена в сети                                           |
| `payout.failed`       | выплата не удалась                                                    |

<Info>
  Финальный статус депозита смотрите в поле `deposit.status` тела события (например,
  `deposit.finalized` может нести `status: "paid"`, `"paid_over"` или `"wrong_amount"`).
  Подробнее о статусах — на странице [Депозиты](/deposits/overview) и [Выплаты](/payouts/overview).
  Тестовый webhook из эндпоинта `/test` приходит с особым `X-Event-Type: webhook.test` —
  это не боевое событие.
</Info>

## Тело webhook'а

Тело — JSON c полями `eventType`, `eventId` и вложенным объектом `deposit` либо `payout`
(в зависимости от ресурса). Поле `eventId` дублирует заголовок `X-Event-Id`.

<CodeGroup>
  ```json deposit.finalized theme={null}
  {
    "eventType": "deposit.finalized",
    "eventId": "3f1b2c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
    "deposit": {
      "uuid": "8f3a1c2e-5b6d-4e7f-9a0b-1c2d3e4f5a6b",
      "orderId": "order-2026-000042",
      "status": "paid",
      "assetCode": "USDT_TRC20",
      "expectedAmount": "100.50",
      "receivedAmount": "100.50",
      "address": "TKh9wq8c4dZsL1mP2nQ3rS4tU5vW6xY7zA",
      "memo": null,
      "txHash": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2",
      "confirmations": 19,
      "requiredConfirmations": 19,
      "finalizedAt": "2026-06-02T20:41:05.000Z"
    }
  }
  ```

  ```json payout.confirmed theme={null}
  {
    "eventType": "payout.confirmed",
    "eventId": "9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d",
    "payout": {
      "uuid": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f",
      "orderId": "payout-2026-000077",
      "status": "confirmed",
      "assetCode": "USDT_TRC20",
      "amount": "50.000000",
      "destinationAddress": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
      "destinationMemo": null,
      "txHash": "f0e9d8c7b6a5948372615f4e3d2c1b0a9f8e7d6c5b4a39281706f5e4d3c2b1a0",
      "explorerTxUrl": "https://tronscan.org/#/transaction/f0e9d8c7b6a5...",
      "confirmations": 19,
      "requiredConfirmations": 19,
      "failReason": null,
      "updatedAt": "2026-06-02T20:56:30.000Z"
    }
  }
  ```
</CodeGroup>

### Поля объекта `deposit`

<ResponseField name="uuid" type="string">
  UUID депозитной транзакции на платформе.
</ResponseField>

<ResponseField name="orderId" type="string | null">
  Ваш `order_id`, переданный при создании депозита (ключ идемпотентности на стороне сайта).
</ResponseField>

<ResponseField name="status" type="string">
  Статус депозита: `paid`, `paid_over`, `wrong_amount`, `fail`, `system_fail`, `refund_paid` и др.
</ResponseField>

<ResponseField name="assetCode" type="string">
  Код актива, например `USDT_TRC20`, `USDT_TON`, `TRX`, `TON`.
</ResponseField>

<ResponseField name="expectedAmount" type="string">
  Ожидаемая сумма (строка — без потери точности).
</ResponseField>

<ResponseField name="receivedAmount" type="string | null">
  Фактически полученная сумма (строка). `null`, если приход ещё не зафиксирован.
</ResponseField>

<ResponseField name="address" type="string">
  Адрес назначения депозита.
</ResponseField>

<ResponseField name="memo" type="string | null">
  Memo/comment для memo-based сетей (TON). `null` для account-based сетей (TRON).
</ResponseField>

<ResponseField name="txHash" type="string | null">
  Хеш входящей транзакции. `null`, если транзакция ещё не замечена.
</ResponseField>

<ResponseField name="confirmations" type="number">
  Текущее число подтверждений.
</ResponseField>

<ResponseField name="requiredConfirmations" type="number">
  Сколько подтверждений требуется для финализации.
</ResponseField>

<ResponseField name="finalizedAt" type="string">
  ISO-8601 момент изменения статуса.
</ResponseField>

### Поля объекта `payout`

<ResponseField name="uuid" type="string">
  UUID выплаты на платформе.
</ResponseField>

<ResponseField name="orderId" type="string | null">
  Ваш `order_id`, переданный при создании выплаты.
</ResponseField>

<ResponseField name="status" type="string">
  Статус выплаты: `broadcasted`, `confirmed`, `failed` и др.
</ResponseField>

<ResponseField name="assetCode" type="string">
  Код актива выплаты.
</ResponseField>

<ResponseField name="amount" type="string">
  Сумма выплаты (строка, отформатирована до `decimals` актива).
</ResponseField>

<ResponseField name="destinationAddress" type="string">
  Адрес получателя.
</ResponseField>

<ResponseField name="destinationMemo" type="string | null">
  Memo/comment получателя для memo-based сетей. `null` для account-based.
</ResponseField>

<ResponseField name="txHash" type="string | null">
  Реальный on-chain хеш транзакции (для TON резолвится мониторингом с задержкой). `null`, пока не отправлено.
</ResponseField>

<ResponseField name="explorerTxUrl" type="string | null">
  Готовая ссылка на транзакцию в обозревателе сети. `null`, если ещё недоступна.
</ResponseField>

<ResponseField name="confirmations" type="number | null">
  Текущее число подтверждений. `null`, пока нет on-chain транзакции.
</ResponseField>

<ResponseField name="requiredConfirmations" type="number | null">
  Требуемое число подтверждений. `null`, пока нет on-chain транзакции.
</ResponseField>

<ResponseField name="failReason" type="string | null">
  Причина ошибки для `payout.failed`. `null` для успешных событий.
</ResponseField>

<ResponseField name="updatedAt" type="string">
  ISO-8601 момент изменения статуса.
</ResponseField>

## Проверка подписи

Сверяйте `X-Signature` с `HMAC-SHA256` от **сырых байтов** запроса. Прочитайте тело
как буфер до парсинга JSON — иначе повторная сериализация изменит подпись.

<CodeGroup>
  ```javascript Node.js (Express) theme={null}
  import crypto from 'node:crypto';
  import express from 'express';

  const app = express();
  const CALLBACK_SECRET = process.env.CALLBACK_SECRET;

  // ВАЖНО: express.raw — получаем сырой Buffer, а не распарсенный объект.
  app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
    const raw = req.body;                       // Buffer — сырые байты тела
    const sig = req.header('X-Signature') ?? '';

    const expected = crypto
      .createHmac('sha256', CALLBACK_SECRET)
      .update(raw)
      .digest('hex');

    // constant-time сравнение; длины должны совпадать
    const a = Buffer.from(expected);
    const b = Buffer.from(sig);
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(401).end();             // подпись не совпала — отклоняем
    }

    const event = JSON.parse(raw.toString('utf8'));

    // Идемпотентность: если event.eventId уже обработан — выходим без побочных эффектов.
    if (alreadyProcessed(event.eventId)) {
      return res.status(200).end();
    }

    // ... ваша бизнес-логика по event.eventType / event.deposit / event.payout ...
    markProcessed(event.eventId);

    res.status(200).end();                       // 2xx в течение 10с = доставлено
  });
  ```

  ```python Python (Flask) theme={null}
  import hmac, hashlib, os
  from flask import Flask, request, abort

  app = Flask(__name__)
  CALLBACK_SECRET = os.environ["CALLBACK_SECRET"].encode()

  @app.post("/webhook")
  def webhook():
      raw = request.get_data()                   # сырые байты тела (не json!)
      sig = request.headers.get("X-Signature", "")

      expected = hmac.new(CALLBACK_SECRET, raw, hashlib.sha256).hexdigest()
      if not hmac.compare_digest(expected, sig): # constant-time
          abort(401)

      event = request.get_json()

      # Идемпотентность по event["eventId"]
      if already_processed(event["eventId"]):
          return "", 200

      # ... ваша бизнес-логика ...
      mark_processed(event["eventId"])
      return "", 200
  ```
</CodeGroup>

<Tip>
  `callback_secret` выдаётся при настройке сайта и доступен только владельцу инстанса.
  Он никогда не передаётся в открытом виде по сети. Если подозреваете компрометацию —
  ротируйте секрет в админ-кабинете; после ротации проверяйте подпись новым значением.
</Tip>

## Политика повторных попыток

Доставка считается **успешной**, если ваш endpoint вернул `HTTP 2xx` в течение **10 секунд**
(тайм-аут настраивается оператором в диапазоне 1–60 секунд). Любой другой исход — не-2xx
ответ, тайм-аут или сетевая ошибка — считается неудачей, и платформа повторяет доставку
по фиксированному расписанию с возрастающими интервалами:

<Steps>
  <Step title="Попытка 2 — через 30 секунд" />

  <Step title="Попытка 3 — через 2 минуты" />

  <Step title="Попытка 4 — через 10 минут" />

  <Step title="Попытка 5 — через 1 час" />

  <Step title="Попытка 6 — через 6 часов" />

  <Step title="Попытка 7+ — через 24 часа (далее интервал не растёт)" />
</Steps>

Максимум **8 попыток** по умолчанию (оператор может настроить от 1 до 20). После исчерпания
попыток доставка помечается как `fail`; такой webhook можно вручную переотправить через
[эндпоинт resend](#тест-и-переотправка).

<Warning>
  Один и тот же `X-Event-Id` приходит на **все** попытки доставки одного события. Обработайте
  событие ровно один раз: при повторе с уже виденным id отвечайте `200` без побочных эффектов.
  Иначе временный сбой на вашей стороне приведёт к двойной обработке оплаты.
</Warning>

<Note>
  Если для сайта включён режим `allow_private_hosts`, `callback_url` может указывать на
  адрес во внутренней сети (например, CMS обменника на том же сервере). По умолчанию
  внутренние/зарезервированные адреса отклоняются SSRF-защитой, и такие попытки логируются
  как неудачные.
</Note>

## Тест и переотправка

Два служебных эндпоинта Public API помогают отладить интеграцию. Оба требуют стандартной
[HMAC-аутентификации](/authentication) Public API (заголовки `X-Api-Id` / `X-Timestamp` /
`X-Signature`) и возвращают результат в общем [конверте ответа](/errors) `{ "ok": true, "data": ... }`.

| Эндпоинт                                    | Действие                                                                                          |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `POST /v1/public/webhooks/test`             | Поставить в очередь **один** тестовый подписанный webhook на ваш `callback_url` (без повторов).   |
| `POST /v1/public/webhooks/resend/{eventId}` | Переотправить ранее сгенерированный webhook по его `X-Event-Id` (с обычным расписанием повторов). |

### `POST /v1/public/webhooks/test`

Ставит в очередь одну попытку доставки тестового webhook'а с теми же заголовками и подписью,
что и у боевых событий, но с особым типом `X-Event-Type: webhook.test`. Удобно проверить, что
ваш endpoint доступен, принимает `POST` и корректно валидирует `X-Signature`. Повторов нет —
ровно одна попытка.

Тело тестового webhook'а, которое получит ваш `callback_url`:

```json Тело тестового webhook theme={null}
{
  "event": "webhook.test",
  "message": "Test webhook from WalletCore",
  "site": "8f3a1c2e-5b6d-4e7f-9a0b-1c2d3e4f5a6b",
  "timestamp": 1780000000
}
```

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://wallet.your-exchange.com/v1/public/webhooks/test \
    -H "X-Api-Id: pk_live_a1b2c3d4" \
    -H "X-Timestamp: 1780000000" \
    -H "X-Signature: 9f1c0b8a7d6e5f4c3b2a1908f7e6d5c4b3a2918f0e7d6c5b4a392817f6e5d4c3"
  ```

  ```javascript Node.js theme={null}
  import crypto from 'node:crypto';

  const BASE = 'https://wallet.your-exchange.com';
  const API_ID = 'pk_live_a1b2c3d4';
  const API_SECRET = process.env.API_SECRET; // не уходит по сети

  const ts = Math.floor(Date.now() / 1000).toString();
  const body = '';                              // POST без тела => пустая строка
  const message = `${ts}.${body}`;              // "<timestamp>."
  const sig = crypto.createHmac('sha256', API_SECRET).update(message).digest('hex');

  const res = await fetch(`${BASE}/v1/public/webhooks/test`, {
    method: 'POST',
    headers: {
      'X-Api-Id': API_ID,
      'X-Timestamp': ts,
      'X-Signature': sig,
    },
  });
  console.log(await res.json());
  ```
</CodeGroup>

<ResponseExample>
  ```json 201 — тест поставлен в очередь theme={null}
  {
    "ok": true,
    "data": {
      "queued": true,
      "url": "https://shop.example.com/webhook",
      "eventId": "1c9b8a7d-6e5f-4c3b-2a19-08f7e6d5c4b3"
    }
  }
  ```
</ResponseExample>

Поля `data`:

| Поле      | Тип       | Описание                                                |
| --------- | --------- | ------------------------------------------------------- |
| `queued`  | `boolean` | `true` — тестовый webhook поставлен в очередь доставки. |
| `url`     | `string`  | `callback_url`, на который будет доставлен тест.        |
| `eventId` | `string`  | UUID этого тестового события (придёт в `X-Event-Id`).   |

### `POST /v1/public/webhooks/resend/{eventId}`

Переотправляет ранее сгенерированный webhook по его `X-Event-Id`. Тело и URL берутся из лога
доставки, подпись пересчитывается под текущий `callback_secret`. Повтор использует обычное
расписание повторных попыток. Переотправлять можно **только события своего сайта** —
доступ к чужим `eventId` невозможен (вернётся `404`).

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST \
    https://wallet.your-exchange.com/v1/public/webhooks/resend/3f1b2c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d \
    -H "X-Api-Id: pk_live_a1b2c3d4" \
    -H "X-Timestamp: 1780000000" \
    -H "X-Signature: 7d6c5b4a392817f6e5d4c3b2a1908f7e6d5c4b3a2918f0e7d6c5b4a392817f6e5"
  ```

  ```javascript Node.js theme={null}
  import crypto from 'node:crypto';

  const BASE = 'https://wallet.your-exchange.com';
  const API_ID = 'pk_live_a1b2c3d4';
  const API_SECRET = process.env.API_SECRET;
  const eventId = '3f1b2c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d';

  const ts = Math.floor(Date.now() / 1000).toString();
  const message = `${ts}.`;                      // POST без тела => "<timestamp>."
  const sig = crypto.createHmac('sha256', API_SECRET).update(message).digest('hex');

  const res = await fetch(`${BASE}/v1/public/webhooks/resend/${eventId}`, {
    method: 'POST',
    headers: {
      'X-Api-Id': API_ID,
      'X-Timestamp': ts,
      'X-Signature': sig,
    },
  });
  console.log(await res.json());
  ```
</CodeGroup>

<ResponseExample>
  ```json 201 — переотправка поставлена в очередь theme={null}
  {
    "ok": true,
    "data": {
      "enqueued": true,
      "eventId": "3f1b2c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
    }
  }
  ```

  ```json 404 — событие не найдено theme={null}
  {
    "ok": false,
    "error": {
      "code": "NOT_FOUND",
      "message": "Webhook event not found"
    }
  }
  ```
</ResponseExample>

Поля `data`:

| Поле       | Тип       | Описание                                    |
| ---------- | --------- | ------------------------------------------- |
| `enqueued` | `boolean` | `true` — переотправка поставлена в очередь. |
| `eventId`  | `string`  | `X-Event-Id` переотправляемого события.     |

## Частые вопросы

<AccordionGroup>
  <Accordion title="Подпись не сходится, хотя секрет верный">
    Почти всегда причина в том, что подпись считается не от сырого тела. Прочитайте тело как
    буфер/строку **до** парсинга JSON и считайте `HMAC-SHA256` именно от этих байтов.
    Повторная сериализация распарсенного объекта меняет порядок ключей и пробелы — подпись
    не совпадёт. В Express используйте `express.raw()`, во Flask — `request.get_data()`.
  </Accordion>

  <Accordion title="Webhook не приходит совсем">
    Проверьте по порядку: настроен ли `callback_url` для сайта; доступен ли ваш endpoint
    извне (или включён ли `allow_private_hosts`, если он во внутренней сети); не режут ли
    запрос ваш firewall/WAF. Затем вызовите `POST /v1/public/webhooks/test` — он поставит
    тестовую доставку и вернёт `url` и `eventId`, по которым видно, куда платформа пыталась
    доставить.
  </Accordion>

  <Accordion title="Получаю один и тот же webhook несколько раз">
    Это ожидаемо: при не-2xx ответе или тайм-ауте платформа повторяет доставку (до 8 раз по
    умолчанию), а одно событие может также прийти повторно после ручного resend. На все
    повторы один и тот же `X-Event-Id`. Дедуплицируйте по нему: при уже обработанном id
    отвечайте `200` без побочных эффектов.
  </Accordion>

  <Accordion title="Сколько у меня времени на ответ?">
    10 секунд по умолчанию (оператор может задать 1–60 секунд). Если обработка дольше —
    примите webhook, поставьте задачу в свою очередь и сразу верните `200`, а тяжёлую логику
    выполняйте асинхронно. Долгий ответ платформа считает неудачей и повторит доставку.
  </Accordion>

  <Accordion title="Чем отличаются deposit.tx_detected и deposit.finalized?">
    `deposit.tx_detected` — первая входящая транзакция замечена, но ещё не набрала нужных
    подтверждений (`receivedAmount`/`txHash` могут быть уже заполнены, но статус не финальный).
    `deposit.finalized` — депозит достиг `requiredConfirmations`; смотрите `deposit.status`
    (`paid` / `paid_over` / `wrong_amount`), чтобы понять исход. Зачислять средства клиенту
    стоит на `deposit.finalized` со статусом `paid`/`paid_over`, не на `tx_detected`.
  </Accordion>

  <Accordion title="resend вернул 404 — почему?">
    Указанный `eventId` не найден среди событий вашего сайта. Переотправлять можно только
    свои события. Проверьте, что `eventId` взят из реально доставлявшегося ранее webhook'а
    (заголовок `X-Event-Id` или поле `eventId` тела) и принадлежит тому же сайту, чьим
    ключом подписан запрос.
  </Accordion>
</AccordionGroup>

## Смежные страницы

* [Аутентификация](/authentication) — как подписывать запросы к Public API (test/resend).
* [Депозиты](/deposits/overview) — статусы депозитов и поля ответа.
* [Выплаты](/payouts/overview) — статусы выплат и поля ответа.
* [Ошибки и конверт ответа](/errors) — формат `{ ok, data }` / `{ ok, error }` и коды ошибок.
