Finance OS / API

B2B Webhooks

Вебхуки - основной способ узнать о событии, которое произошло не по вашему запросу: поступление на кошелёк клиента, подтверждение вывода сетью, заморозка по комплаенсу, низкий остаток мастер-счёта. Finance OS отправляет на ваш HTTPS-эндпоинт подписанный POST и повторяет доставку, пока не получит 2xx.

Опрос не заменяет вебхуки
Часть событий не имеет соответствующего вызова API: узнать о заморозке депозита или о том, что вывод подтверждён сетью, можно только из вебхука либо частым опросом истории. Для денежных сценариев подпишитесь на вебхуки - это единственный надёжный канал.

События

Кошельки клиентов

customer.deposit.detected optional
Поступление обнаружено в сети и уже зачислено на баланс клиента (0 подтверждений). Поля: transaction_id, network, asset, amount, status, tx_hash.
customer.deposit.confirmed optional
Сеть набрала нужное число подтверждений. Поля: transaction_id, network, asset, amount, confirmations, tx_hash.
customer.deposit.frozen optional
Поступление заморожено по результату комплаенс-проверки. Поля: transaction_id, network, asset, amount, frozen_amount, risk_level, blocked (клиент заблокирован автоматически).
customer.deposit.released optional
Заморозка снята комплаенс-офицером, деньги вернулись в доступный остаток. Поля: transaction_id, asset, network, amount.

Выводы и расчёты

customer.withdrawal.processing optional
Заявка принята, деньги списаны с клиента, отправки ещё не было.
customer.withdrawal.sent optional
Транзакция ушла в сеть, в tx_hash есть хеш.
customer.withdrawal.confirmed optional
Сеть подтвердила вывод. Терминальное событие.
customer.withdrawal.failed optional
Отправить не удалось, сумма и комиссии возвращены клиенту. Терминальное событие.
customer.withdrawal.blocked optional
Вывод отклонён комплаенс-проверкой адреса получателя до списания. Поля: network, asset, address, risk_level.
customer.charged optional
С клиента списано в вашу пользу. Поля: operation_id, network, asset, amount, reason, balance.
customer.payout optional
Клиенту выплачено с вашего мастер-счёта. Поля те же, что у customer.charged.

Четыре события жизненного цикла вывода (processing, sent, confirmed, failed) несут одинаковый набор полей: withdrawal_id, network, asset, amount, address, status, state, fee.platform, fee.merchant, tx_hash. Значение withdrawal_id совпадает с data.id из ответа на создание заявки.

Клиент и мастер-счёт

customer.created optional
Зарезервировано: подписка принимается, но событие пока не отправляется. Факт создания клиента вы и так знаете из ответа POST /api/v1/customers.
customer.blocked optional
Клиент заблокирован. Поля: source (merchant / officer / sanctions) и reason.
customer.unblocked optional
Блокировка снята. Поле by - кем.
merchant.balance.credited optional
На мастер-счёт зачислен перевод. Поля: currency, amount, balance, network, asset.
merchant.balance.low optional
Остаток мастер-счёта ниже порога. Поля: currency, balance, threshold. Не чаще одного раза в сутки.

Верификация и платежи

customer.kyc.processing optional
Документы загружены, идёт проверка.
customer.kyc.verified optional
Верификация пройдена.
customer.kyc.rejected optional
Документы отклонены, причина в kyc_rejection_reason.
payment.buy.initiated optional
Покупка криптовалюты за рубли создана.
payment.buy.completed optional
Оплата получена, криптовалюта зачислена.
payment.buy.failed optional
Платёж не прошёл.
payment.sell.completed optional
Выплата в рублях отправлена.
payment.sell.failed optional
Выплата не прошла, резерв возвращён в доступный остаток.
payment.deposit.detected optional
Входящий крипто-платёж обнаружен в сети.
payment.deposit.confirmed optional
Входящий крипто-платёж подтверждён.
payment.withdraw.completed optional
Вывод подтверждён в сети.
payment.withdraw.failed optional
Вывод не прошёл.
webhook.test optional
Тестовое событие, отправляется вручную через POST /webhooks/{id}/test.

События customer.kyc.* несут customer_uuid, external_id, kyc_status, kyc_level и kyc_rejection_reason. События payment.* - transaction_uuid, customer_uuid, external_id, type, status, amount_from, amount_to, currency_from, currency_to, tx_hash.

Подписка точная, без масок
Эндпоинт получает только те события, которые перечислены в его events. Шаблоны вида customer.* не поддерживаются - перечисляйте события полностью. Неизвестное имя события отклоняется валидацией при создании подписки.

Подписка

POST /api/v1/webhooks
Bearer Token

Зарегистрировать эндпоинт

Body

url string required
Адрес, на который приходят события. Обязательно https://, до 500 символов. http:// отклоняется.
events array required
Непустой список имён событий из таблиц выше.

Responses

{
  "data": {
    "id": 42,
    "url": "https://your-app.com/webhooks/finos",
    "events": ["customer.deposit.confirmed", "customer.withdrawal.confirmed", "merchant.balance.low"],
    "env": "live",
    "active": true,
    "secret_last4": "wxyz",
    "created_at": "2026-08-17T11:23:00.000000Z"
  },
  "secret": "whsec_ВАШ_СЕКРЕТ",
  "notice": "Save this secret now - it will not be shown again. Use it to verify the X-FinOs-Signature header on incoming webhooks."
}
Секрет показывается один раз
После 201 секрет хранится только в зашифрованном виде. Если потеряли - POST /api/v1/webhooks/{id}/rotate-secret выдаст новый, и старый перестанет действовать немедленно: обновите проверку подписи до следующей доставки.

Остальные эндпоинты

GET /api/v1/webhooks optional
Список ваших подписок текущего окружения.
PATCH /api/v1/webhooks/{id} optional
Изменить url, events или active. Отключение (active: false) останавливает доставку, не удаляя подписку.
DELETE /api/v1/webhooks/{id} optional
Удалить подписку. Ответ 204.
POST /api/v1/webhooks/{id}/rotate-secret optional
Выдать новый секрет. Прежний недействителен сразу.
POST /api/v1/webhooks/{id}/test optional
Отправить событие webhook.test и сразу вернуть результат: delivered, last_success, last_failure.

Подписки изолированы по окружению: ключ sk_test_ создаёт и видит только sandbox-подписки, sk_live_ - только боевые. Обращение к чужой подписке даёт 404.

Лимиты частоты

Запросов в минуту

Чтение списка подписок (<code>GET /api/v1/webhooks</code>) 120 optional
Читающая корзина раздела.
Изменение подписок: создание, <code>PATCH</code>, <code>DELETE</code>, ротация секрета, тестовое событие 30 optional
Общая корзина мутирующих вызовов подписок - эти 30 в минуту делятся между всеми пятью методами, это не 30 на каждый.

При превышении приходит 429 rate_limited с заголовком Retry-After. Управление подписками - разовая настройка: если вы упираетесь в лимит, скорее всего эндпоинт пересоздаётся в цикле вместо PATCH.

Формат события

Тело всегда состоит из пяти полей верхнего уровня, и порядок ключей фиксирован - подпись считается от готовой строки.

{
  "id": "evt_5f8d7a3c-1234-4567-89ab-cdef01234567",
  "type": "customer.deposit.confirmed",
  "created_at": "2026-08-17T12:11:40+00:00",
  "env": "live",
  "data": {
    "customer_uuid": "5f8d7a3c-1234-4567-89ab-cdef01234567",
    "external_id": "user-42",
    "transaction_id": "7c1f2b90-4a3e-4a1f-9c62-8b0f4a7d2e11",
    "network": "TRON",
    "asset": "USDT",
    "amount": "150.000000000000000000",
    "confirmations": 21,
    "tx_hash": "3f0c1b7a94e2d5c86a1f0b2d4e7c9a51b3d6f8e0a2c4b6d8f0e1a3c5b7d9f2e4"
  }
}

Поля конверта

id string optional
Идентификатор события с префиксом evt_. Уникален и не меняется при повторных доставках - используйте его для дедупликации.
type string optional
Имя события.
created_at datetime optional
Момент формирования события (ISO 8601).
env enum optional
live или sandbox.
data object optional
Полезная нагрузка события. У событий по клиенту всегда содержит customer_uuid и external_id, у событий мастер-счёта - merchant_uuid.
Состав data может пополняться
Читайте нужные поля по имени и игнорируйте незнакомые: добавление нового поля в data не считается ломающим изменением. Внутренние идентификаторы платформы и имена инфраструктурных поставщиков в полезную нагрузку не попадают.

Заголовки запроса

Content-Type: application/json
X-FinOs-Signature: sha256=<hex>
X-FinOs-Event: customer.deposit.confirmed
X-FinOs-Event-Id: evt_5f8d7a3c-1234-4567-89ab-cdef01234567
X-FinOs-Webhook-Id: 42
User-Agent: Finance-OS-Webhooks/1.0
X-FinOs-Signature string optional
Подпись в формате sha256=<hex>: HMAC-SHA256 от сырого тела запроса с секретом подписки.
X-FinOs-Event string optional
Имя события - дубль поля type. Позволяет отбросить неинтересное событие, не разбирая тело.
X-FinOs-Event-Id string optional
Идентификатор события - дубль поля id. Удобен для дедупликации до разбора JSON.
X-FinOs-Webhook-Id integer optional
Идентификатор вашей подписки - полезен, когда на один обработчик заведено несколько эндпоинтов.

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

Подпись считается по сырому телу запроса - ровно по тем байтам, что пришли. Не разбирайте JSON и не собирайте его заново перед проверкой: перестановка ключей или изменение экранирования сломают совпадение.

Сравнивайте за постоянное время
Используйте hash_equals (PHP), crypto.timingSafeEqual (Node), hmac.compare_digest (Python) - не === и не обычное сравнение строк, иначе атака по времени позволяет подобрать подпись. Запрос без корректной подписи обрабатывать нельзя.

Повторы доставки

Обработчик обязан ответить кодом 2xx в течение 10 секунд. Любой другой код, таймаут или сетевая ошибка считаются неудачей, и доставка уходит на лестницу повторов.

Лестница повторов

Попытка 2 через 1 мин optional
Попытка 3 через 5 мин optional
Попытка 4 через 15 мин optional
Попытка 5 через 1 ч optional
Попытка 6 через 6 ч optional
Попытка 7 через 1 сут optional
Последняя автоматическая попытка.

Итого 7 попыток примерно за сутки. Если ни одна не удалась, доставка помечается как невручённая и автоматических попыток больше не будет - оператор платформы получает тревогу, а вы можете повторить доставку вручную из личного кабинета. Повтор отправляет то же самое тело с тем же id, байт в байт, поэтому подпись и дедупликация на вашей стороне продолжают работать.

Отвечайте быстро, обрабатывайте потом
Десять секунд - это весь бюджет ответа. Принимайте событие, кладите его в свою очередь и сразу отвечайте 200. Тяжёлая синхронная обработка внутри обработчика приводит к таймауту, лишним повторам и дублям у вас же.

Идемпотентная обработка

Повтор доставки - штатная ситуация: ваш сервер мог получить событие и не успеть ответить. Сохраняйте обработанные id и на повторе сразу отвечайте 200, ничего не делая. Порядок доставки не гарантирован: customer.deposit.confirmed может прийти раньше, чем вы обработали customer.deposit.detected - опирайтесь на поля события, а не на очерёдность.

Что не приходит вебхуком

События верификации (customer.kyc.*) и платёжного контура (payment.*) отправляются однократно, без лестницы повторов. Для них дополнительно сверяйтесь опросом статуса. События крипто-кошельков клиентов, расчётов и мастер-счёта идут через журнал доставок с повторами, описанными выше.

Проверка канала

POST /api/v1/webhooks/{id}/test
Bearer Token

Отправить тестовое событие webhook.test

Синхронно отправляет событие и возвращает результат попытки. Удобно при настройке: проверяет, что эндпоинт достижим, принимает подпись и отвечает 2xx. Подписка на webhook.test для этого не требуется.

Responses

{
  "data": {
    "webhook_id": 42,
    "delivered": true,
    "last_success": "2026-08-17T11:25:31.000000Z",
    "last_failure": null
  },
  "notice": "Test event delivered successfully (2xx received)."
}

Примеры кода

KEY="sk_live_..."
B="https://fin-os.io/api/v1"

# 1. Подписка на события контура кошельков
curl -X POST $B/webhooks \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.com/webhooks/finos",
    "events": [
      "customer.deposit.detected",
      "customer.deposit.confirmed",
      "customer.deposit.frozen",
      "customer.withdrawal.sent",
      "customer.withdrawal.confirmed",
      "customer.withdrawal.failed",
      "merchant.balance.low"
    ]
  }'
# Сохраните "secret" из ответа - он больше не показывается

# 2. Проверить канал
curl -X POST $B/webhooks/$ID/test -H "Authorization: Bearer $KEY"

# 3. Временно отключить доставку, не удаляя подписку
curl -X PATCH $B/webhooks/$ID \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{"active": false}'

# 4. Ротация секрета (старый перестаёт действовать немедленно)
curl -X POST $B/webhooks/$ID/rotate-secret -H "Authorization: Bearer $KEY"
// Express: сырое тело обязательно, иначе подпись не сойдётся
import express from 'express';
import crypto from 'crypto';

const app     = express();
const SECRET  = process.env.FINOS_WEBHOOK_SECRET;
const seen    = new Set(); // в проде - постоянное хранилище

app.post('/webhooks/finos', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = Buffer.from(req.header('X-FinOs-Signature') || '');
  const expected  = Buffer.from(
    'sha256=' + crypto.createHmac('sha256', SECRET).update(req.body).digest('hex')
  );

  if (signature.length !== expected.length ||
      !crypto.timingSafeEqual(signature, expected)) {
    return res.status(401).send('Invalid signature');
  }

  // Дедупликация до разбора тела - id продублирован в заголовке
  const eventId = req.header('X-FinOs-Event-Id');
  if (seen.has(eventId)) return res.sendStatus(200);
  seen.add(eventId);

  const event = JSON.parse(req.body.toString());

  // Отвечаем сразу, обрабатываем в своей очереди: бюджет ответа - 10 секунд
  queue.push(event);
  res.sendStatus(200);
});
// Laravel: контроллер приёма вебхуков
class FinosWebhookController extends Controller
{
    public function handle(Request $request)
    {
        $expected = 'sha256=' . hash_hmac(
            'sha256',
            $request->getContent(),                       // сырое тело, не перекодированный JSON
            config('services.finos.webhook_secret'),
        );

        if (! hash_equals($expected, (string) $request->header('X-FinOs-Signature'))) {
            abort(401, 'Invalid signature');
        }

        $event = $request->json()->all();

        // Повтор доставки - штатная ситуация: id не меняется между попытками
        if (WebhookEvent::where('event_id', $event['id'])->exists()) {
            return response()->noContent();
        }

        WebhookEvent::create(['event_id' => $event['id'], 'type' => $event['type']]);

        match ($event['type']) {
            'customer.deposit.confirmed'    => CreditOrder::dispatch($event['data']),
            'customer.deposit.frozen'       => NotifyCompliance::dispatch($event['data']),
            'customer.withdrawal.confirmed' => CloseWithdrawal::dispatch($event['data']),
            'customer.withdrawal.failed'    => ReopenWithdrawal::dispatch($event['data']),
            'merchant.balance.low'          => TopUpServiceBalance::dispatch($event['data']),
            default                         => logger()->info('Unhandled webhook', $event),
        };

        return response()->noContent();   // 204 тоже 2xx
    }
}
# Flask
import hmac, hashlib, os
from flask import Flask, request, abort

app    = Flask(__name__)
SECRET = os.environ['FINOS_WEBHOOK_SECRET'].encode()
seen   = set()  # в проде - постоянное хранилище

@app.route('/webhooks/finos', methods=['POST'])
def handle():
    expected = 'sha256=' + hmac.new(SECRET, request.data, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(request.headers.get('X-FinOs-Signature', ''), expected):
        abort(401)

    event_id = request.headers.get('X-FinOs-Event-Id')
    if event_id in seen:
        return '', 200
    seen.add(event_id)

    event = request.get_json()
    data  = event['data']

    if event['type'] == 'customer.deposit.confirmed':
        credit_order(data['customer_uuid'], data['asset'], data['amount'])
    elif event['type'] == 'customer.withdrawal.failed':
        reopen_withdrawal(data['withdrawal_id'])
    elif event['type'] == 'merchant.balance.low':
        top_up_service_balance(data['balance'], data['threshold'])

    # Отвечаем сразу: бюджет ответа - 10 секунд
    return '', 200