B2B Webhooks
Вебхуки - основной способ узнать о событии, которое произошло не по вашему запросу: поступление на кошелёк клиента, подтверждение вывода сетью, заморозка по комплаенсу, низкий остаток мастер-счёта. Finance OS отправляет на ваш HTTPS-эндпоинт подписанный POST и повторяет доставку, пока не получит 2xx.
События
Кошельки клиентов
customer.deposit.detected
optional
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.| Name | Type | Required | Description |
|---|---|---|---|
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.| Name | Type | Required | Description |
|---|---|---|---|
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. Не чаще одного раза в сутки.| Name | Type | Required | Description |
|---|---|---|---|
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.| Name | Type | Required | Description |
|---|---|---|---|
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.* не поддерживаются - перечисляйте события полностью. Неизвестное имя события отклоняется валидацией при создании подписки.
Подписка
/api/v1/webhooks
Зарегистрировать эндпоинт
Body
url
string
required
https://, до 500 символов. http:// отклоняется.events
array
required
| Name | Type | Required | Description |
|---|---|---|---|
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.| Name | Type | Required | Description |
|---|---|---|---|
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 на каждый.| Name | Type | Required | Description |
|---|---|---|---|
Чтение списка подписок (<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
env
enum
optional
live или sandbox.data
object
optional
customer_uuid и external_id, у событий мастер-счёта - merchant_uuid.| Name | Type | Required | Description |
|---|---|---|---|
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 не считается ломающим изменением. Внутренние идентификаторы платформы и имена инфраструктурных поставщиков в полезную нагрузку не попадают.
Заголовки запроса
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
| Name | Type | Required | Description |
|---|---|---|---|
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
| Name | Type | Required | Description |
|---|---|---|---|
Попытка 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.*) отправляются однократно, без лестницы повторов. Для них дополнительно сверяйтесь опросом статуса. События крипто-кошельков клиентов, расчётов и мастер-счёта идут через журнал доставок с повторами, описанными выше.
Проверка канала
/api/v1/webhooks/{id}/test
Отправить тестовое событие 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