# Вебхуки

Уведомления о платежах, проверка подписи и обработка повторов.

Source: https://docs.invoise.me/ru/integration/webhooks/

Вебхук — HTTP-запрос от Invoise на ваш сервер при событии входящего перевода, инвойса или пейаута. Используйте [GET состояния инвойса или депозита](https://docs.invoise.me/ru/payments/status/) для проверки его текущего состояния.

## 1. Зарегистрируйте адрес

```bash
curl -X POST 'https://platform.invoise.me/api/v1/shops/{shop_id}/webhooks' \
  -H 'Authorization: Bearer ivk_...' \
  -H 'Idempotency-Key: <saved-unique-key>' \
  -H 'Content-Type: application/json' \
  -d '{"url":"https://example.com/invoise","filters":["transfer","invoice","payout"]}'
```

Укажите свой публичный HTTPS-адрес. Локальные и приватные адреса не принимаются. Сохраните `secret` из ответа на создание — им проверяется подпись запросов.

У магазина может быть до 10 адресов. URL существующего адреса можно изменить, поэтому используйте его вместо нового. См. [Лимиты](https://docs.invoise.me/ru/integration/idempotency-and-errors/#лимиты).

## 2. Выберите события

| Событие | Что означает |
| --- | --- |
| `transfer.observed` | Перевод замечен, но ещё не подтверждён. |
| `transfer.confirmed` | Входящий перевод получил достаточно подтверждений. Это может быть лишь частичная оплата инвойса. |
| `transfer.reverted` | Предыдущее событие перевода отменено. |
| `invoice.closed` | Инвойс закрыт в блокчейне. |
| `invoice.cancelled` | Checkout отменён или срок инвойса истёк; это не возврат денег. |
| `payout.sent` | Пейаут отправлен в сеть или замечен исходящий перевод. |
| `payout.confirmed` | Полный учёт пейаута подтверждён. |
| `payout.reverted` | Предыдущее подтверждение пейаута отменено. |
| `payout.failed` | Окончательная ошибка пейаута требует вмешательства. |

У `invoice.cancelled` поле `data.reason` объясняет причину: `expired` — истёк [срок действия инвойса](https://docs.invoise.me/ru/payments/deposits-and-invoices/#срок-действия-инвойса), `merchant_blocked` — сотрудники Invoise заблокировали мерчанта. При отмене самим мерчантом `reason` нет.

Подписывайтесь по группам: `transfer`, `invoice`, `payout`. Входящая оплата и исходящий пейаут — разные события. Временная ошибка RPC или ожидание газа не означают `payout.failed`.

`gas.wait` и `sweep.failed` принимаются как фильтры, но Invoise не отправляет их на ваш адрес. Это внутренние сигналы: `gas.wait` — пейаут ждёт средств на комиссию сети и продолжится сам; `sweep.failed` — попытка перевести средства получателю не удалась. Ни то, ни другое не требует от вас действий. Пейаут, который не может завершиться, придёт как `payout.failed`.

В sandbox-магазине оба сигнала можно симулировать через `POST /shops/{shop_id}/sandbox/simulate`, чтобы проверить, что задержка пейаута не ломает ваш сценарий. Они меняют только состояние пейаута и не отправляют вебхук.

## 3. Проверьте подпись

Заголовок `Invoise-Signature` имеет вид `t=<timestamp>,v1=<hex>`. Подпись — HMAC-SHA256 строки `timestamp + "." + raw_body` с секретом вебхука.

Пример для Node.js также отклоняет время с расхождением более пяти минут. Часы сервера должны быть синхронизированы; допустимое расхождение выбирайте осознанно.

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verify(rawBody, header, secret) {
  const match = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(header ?? '');
  if (!match) return false;
  const [, timestamp, signature] = match;
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(age) || age > 300) return false;
  const expected = createHmac('sha256', secret)
    .update(timestamp + '.')
    .update(rawBody)
    .digest();
  return timingSafeEqual(Buffer.from(signature, 'hex'), expected);
}
```

В `rawBody` передавайте исходные байты запроса. Нельзя сначала разобрать JSON, собрать его заново и проверять подпись уже над ним. Неверную подпись отклоняйте до сохранения и обработки содержимого.

## 4. Обработайте событие один раз

Сокращённый пример:

```json
{
  "id": "<event-id>",
  "type": "transfer.confirmed",
  "created_at": "2026-09-19T10:00:00Z",
  "data": {
    "issuance_id": "<issuance-id>",
    "shop_id": "<shop-id>",
    "external_id": "order-123",
    "type": "invoice",
    "invoice_cancelled": false
  }
}
```

| Ситуация | Обработка |
| --- | --- |
| Дубли | Дубли определяйте по `Invoise-Event-ID`, совпадающему с `id` конверта. Сохраняйте ID и изменение бизнес-данных в одной транзакции. |
| Приём события | Надёжно сохраните принятое событие, быстро ответьте 2xx и выполняйте остальную работу из очереди. |
| Повторная доставка | Повторы и ручная доставка сохраняют ID события. `Invoise-Attempt-ID` меняется и не подходит для защиты от дублей. |
| Порядок событий | Порядок прихода не гарантируется. Смотрите на содержимое и при необходимости сверяйтесь с [текущим статусом](https://docs.invoise.me/ru/payments/status/). |

При отмене `chain_event.reference_event_id` ссылается на предыдущее событие сети. Пейауты содержат стабильный `payout_id`, хэш транзакции, суммы и комиссию; неизвестные суммы равны `null`. Список ID входящих переводов ограничен 100 записями и помечает усечение.

В старых сохранённых событиях может быть `project_id`: сначала читайте `shop_id`, а при его отсутствии — `project_id`. Повторная доставка сохраняет исходные байты.

## Проверить доставку

История доступна через `GET /api/v1/shops/{shop_id}/deliveries`. Повторить доставку можно через `POST /api/v1/shops/{shop_id}/deliveries/{id}/replay` с сохранённым ключом идемпотентности. Нового бизнес-события от этого не появляется.
