Обратный вызов (Callbacks / Webhooks)
Получение и проверка подлинности серверных уведомлений о статусах платежей и выплат в Syncra V2
Серверные уведомления (Callbacks / Webhooks)
Для информирования вашей системы о смене статусов сделок, выводов и изменениях каталога платёжных методов шлюз Syncra V2 отправляет асинхронные HTTP POST-запросы на указанный вами адрес обратного вызова (Callback URL). Адрес доставки определяется иерархией:
- Per-deal
callback_url— поле запросаCreateMerchantPayIn(POST /deals/payin): если задано, доставка для этой сделки идёт на него. merchant.webhook_url— адрес, настроенный на уровне мерчанта в админ-панели; используется, когда per-deal override не задан.- Оба пусты — колбэк для сделки не создаётся (уведомление не отправляется).
Per-deal callback_url — только PayIn. В запросе
POST /deals/payout такого поля нет: уведомления о выплатах всегда идут
на merchant.webhook_url мерчанта.
callback_url переопределяет только адрес доставки. Он не отменяет
предусловие создания сделки: webhook_url и webhook secret на аккаунте
мерча обязательны ВСЕГДА (Stripe-модель: endpoint настраивается на
аккаунте). Без аккаунт-level настройки создание сделки → 400 FAILED_PRECONDITION "merchant webhook URL is not configured".
История доставок (Delivery Log)
Каждая отправка вебхука фиксируется записью доставки. Мерчант читает свою историю через API (также доступна на вкладке «Вебхуки» кабинета):
GET /api/v1/p2p/merchant/callbacksКанонический путь — внутри merchant-префикса. Историческое зеркало
GET /api/v1/p2p/callbacks (без /merchant) продолжает работать для
wire-compat и считается deprecated — новые интеграции стройте на
каноническом пути. Поведение обоих биндингов идентично.
Параметры (Query)
| Параметр | Тип | Описание |
|---|---|---|
deal_id | string(UUID) | Фильтр по сделке (опционально). Ветка deal_id возвращает весь трейл доставки этой сделки без пагинации. |
page_size | int32 | Строк на страницу (1–100, по умолчанию 20). |
page_token | string | Непрозрачный keyset-курсор (base64-JSON тапла created_at,id последней строки предыдущей страницы). Пустая строка = первая страница. |
Пример ответа (200 OK, живой ответ стейджа)
{
"data": [
{
"callback_id": "8b23b294-8af5-4086-8ea3-2fa8569b30f6",
"deal_id": "9512d99a-3bbc-4a32-acae-d6ad8bb7e32f",
"merchant_id": "3e52c59c-3013-4efa-a2db-70cef2adc7ab",
"callback_url": "https://merchant.example.com/webhook",
"status": "SUCCESS",
"retry_count": 1,
"response_code": 200,
"response_body": "{\"status\":\"ok\"}",
"next_retry_at": "",
"created_at": "2026-08-23T19:14:34Z"
}
],
"next_page_token": "eyJ0IjoiMjAyNi0wOC0yM1QxOToxNDozNC44MzE3OTVaIiwiaWQiOiJiMTFmN2ZiNC0zMTUzLTRlZDktOGExZS05MjNlNzlhMGQ4NTQifQ=="
}next_page_token пуст = последняя страница. Курсор opaque — передавайте
обратно как есть.
Поля записи Callback
| Поле | Тип | Описание |
|---|---|---|
callback_id | string(UUID) | Идентификатор записи доставки. |
deal_id | string(UUID) | Сделка, о которой уведомление. |
merchant_id | string(UUID) | Мерчант-владелец. |
callback_url | string | Адрес доставки (per-deal override или webhook_url мерчанта). |
status | string | PENDING (в очереди) / SUCCESS (доставлено, 2xx) / RETRY (ошибка, ждёт авто-повтора) / ERROR (попытки исчерпаны — терминальное). |
retry_count | int32 | Число попыток доставки. |
response_code | int32 | HTTP-код последнего ответа вашего эндпоинта. |
response_body | string | Тело последнего ответа (обрезается). |
next_retry_at | string(RFC3339) | Время следующей авто-попытки (пусто, если повторов не планируется). |
created_at | string(RFC3339) | Время создания записи. |
Автоматическая лестница повторов (V2)
Успехом доставки считается любой ответ ниже 400 (включая 2xx и 3xx);
ответ 4xx/5xx, сетевая ошибка или превышение webhook_timeout_sec
(по умолчанию 10 с) — неудачей. Неудачная доставка повторяется по лестнице
экспоненциальных пауз (пауза после N-й неудачной попытки):
| Номер неудачной попытки | Пауза до следующей попытки |
|---|---|
| 1 | 5 s |
| 2 | 30 s |
| 3 | 5 min |
| 4 | 15 min |
| 5 и далее | 1 h |
При лимите по умолчанию (max_retries = 5, per-merchant) доставка
пробуется ровно 5 раз суммарно — исходная попытка плюс 4 повтора
(паузы 5 s → 30 s → 5 min → 15 min); ступень 1 h достижима только при
повышенном per-merchant max_retries. После исчерпания попыток запись
переходит в терминальный ERROR и эмитится outbox-событие об исчерпании.
Для api_version=v1 повторов нет — любая ошибка доставки терминальна
(см. V1 compat ниже).
Тело ответа вашего эндпоинта сохраняется в историю доставок обрезанным до
2048 байт (response_body).
Ручной retry колбэка
Запись в терминальном ERROR (или зависшую в RETRY) можно пере-поставить
в очередь немедленной доставки — аналог кнопки Retry в Stripe Dashboard
(в кабинете — на вкладке «Вебхуки»):
POST /api/v1/p2p/merchant/callbacks/{callback_id}/retryТело запроса — пустой JSON-объект {}. Аутентификация: HMAC-пара или
кабинетный JWT (dual-homed).
Правила (в порядке проверки)
- Только свой колбэк: чужой
callback_id→403 PERMISSION_DENIED(cannot access another merchant's resources), несуществующий →404(callback not found). - Только
ERROR/RETRY: дляPENDING/SUCCESS→400 FAILED_PRECONDITION(см. живой пример ниже). - Cooldown 60 секунд на один колбэк (независимо от авто-попыток):
повторный вызов раньше →
429 RESOURCE_EXHAUSTED.
Пример успешного ответа (200 OK)
Возвращается пере-поставленная запись: status=PENDING,
next_retry_at≈now, поля ответа очищены, retry_count сохранён
(ручной retry — дополнительная попытка в том же трейле; админский сброс,
в отличие от мерчантского, счётчик обнуляет).
Живые ошибки стейджа
400 — колбэк не в состоянии ошибки (живой ответ):
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "{\"code\":9,\"message\":\"callback is not in a retryable state (only ERROR and RETRY): status SUCCESS\",\"details\":[]}",
"instance": "/api/v1/p2p-engine"
}404 — неизвестный callback_id (живой ответ):
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "{\"code\":5,\"message\":\"callback not found: callback not found\",\"details\":[]}",
"instance": "/api/v1/p2p-engine"
}429 — cooldown 60 с:
{
"type": "about:blank",
"title": "Too Many Requests",
"status": 429,
"detail": "{\"code\":8,\"message\":\"last manual retry 12s ago (cooldown 1m0s)\",\"details\":[]}",
"instance": "/api/v1/p2p-engine"
}Подпись запроса (Webhook Signature)
Каждый исходящий вебхук V2 подписывается тем же алгоритмом HMAC-SHA256,
что используется для проверки входящих запросов мерчанта (merchantsig).
Подпись передаётся в HTTP-заголовке:
- V2:
X-Signature: t={unix_timestamp},v1={hex_hmac_sha256} - V1 compat:
SIGNATURE(HMAC-SHA512 от{startOfDayUTC};{salt})
В V2 заголовок называется X-Signature (НЕ X-Syncra-Signature).
Реализация: callback_worker.go:201 — req.Header.Set("X-Signature", cb.Signature). Для legacy-мерчантов с api_version=v1 дополнительно
выставляется заголовок SIGNATURE (без X--префикса).
Формат заголовка V2
X-Signature: t=1783856120,v1=9e87d0c3c8801d0a5198d023b6b19a16f2c8d2037920ab3e09819cd8e412cfabt— Unix timestamp (UTC) момента формирования подписи.v1— hex HMAC-SHA256 от строки"{t}.{raw_body}", гдеraw_body— сырое тело запроса (без нормализации), ключ —webhook_secretмерчанта.
Подпись и тело запроса гарантированно разделяют один байтовый источник:
v1 вычисляется от байтов тела ровно в том виде, в котором они будут
доставлены (фикс от 2026-08-22: ранее подпись могла считаться от
сериализации, отличной от отправленных байтов, из-за чего HMAC,
посчитанный мерчантом по полученному телу, не совпадал). Вердиктируйте
по полученным байтам — совпадение будет.
V1 compat-формат (для мигрирующих мерчантов)
В legacy-вебхуках V1 временная метка подписи формируется строго как начало текущих суток в UTC (полночь, 00:00:00):
timestamp = DateTime.UtcNow.Date.ToUnixTimestamp()
signature = HMACSHA512("{timestamp};{salt}", merchantSecret)V1 spec: повторных попыток нет (no retry) — любая ошибка доставки терминальна.
Формат уведомления V2
Тело — JSON, структура CallbackEventPayload (11 полей) для
lifecycle-событий сделок/выплат/выводов:
{
"event": "deal.completed",
"deal_id": "7ac148fe-19a3-45bb-b992-019fac55b721",
"merchant_id": "8f870a31-b88d-4cb0-a882-628d08cbda88",
"tenant_id": "550e8400-e29b-41d4-a716-446655440000",
"amount": 250050,
"currency": "RUB",
"status": "COMPLETED",
"timestamp": "2026-07-12T17:24:12Z",
"received_amount": 250050,
"amount_match_status": "full",
"provider_metadata": {
"bank_code": "sbp",
"rrn": "123456789012"
}
}Исключение — событие каталога payment_methods.updated: для него
используется отдельный конверт без полей сделки (amount, currency,
deal_id) — см. раздел
События каталога платёжных методов.
Поля CallbackEventPayload
| Поле | Тип | Обяз. | Описание |
|---|---|---|---|
event | string | Да | Имя события (см. таблицу ниже). |
deal_id | string(UUID) | Да | Идентификатор сделки (Order.ID или PayOut.ID для payout-событий, withdrawal_id для withdrawal-событий). |
merchant_id | string(UUID) | Да | Идентификатор мерчанта — владельца сделки. |
tenant_id | string(UUID) | Да | Идентификатор тенанта. |
amount | int64 | Да | Сумма в минорных единицах (копейки/центы/satoshi). |
old_amount | int64 | Нет | Сумма ДО изменения — только в deal.amount_changed. |
amount_version | int64 | Нет | Монотонный счётчик правок суммы — только в deal.amount_changed (начальное значение сделки — 1, каждая правка +1). |
currency | string | Да | ISO 4217 (RUB, KZT, ...). Для USDT-выводов — USDT. |
status | string | Да | Текущий canonical статус: OrderStatus (PayIn), PayOutStatus (PayOut), WithdrawalStatus (вывод). |
timestamp | string(RFC3339) | Да | UTC-время формирования события. |
received_amount | int64 | Нет | Фактически полученная от плательщика сумма. Только для PayIn после верификации. |
amount_match_status | string | Нет | Результат сверки сумм при PayIn: full / partial / overpaid (строго lowercase). |
provider_metadata | object | Нет | Провайдер-специфичные метаданные (чувствительные данные вроде PAN редуцируются). |
Все int64 в телах вебхуков передаются строками — тот же канон, что и
в REST-ответах (proto3 JSON): "amount": "330000",
"amount_version": "2".
Один парсер сумм на всю интеграцию; голое JSON-число нигде не приходит.
В V1-конверте формат наследует легаси-контракт V1.
amount_match_status передаётся строго lowercase: full, partial,
overpaid. Значения MATCHED, UNDERPAID, OVERPAID (uppercase) в V2
не существуют — это legacy-артефакты.
Платёжные реквизиты в вебхуках не передаются — никогда. Тела вебхуков (V2
CallbackEventPayload и legacy V1-конверт) содержат только идентификаторы,
суммы, валюту и статус: полные PAN/IBAN/телефоны мерчанту не отправляются
(PII-минимизация). Исчерпывающий источник реквизитов для PayIn — публичный
GET /api/v1/p2p/checkout/{hash} (см. SDK Examples
§9). В
V1-конверте поле cardNumber приходит только замаскированным
(**** **** **** 1234 — последние 4 цифры); в provider_metadata
чувствительные значения (PAN, IBAN, номера телефонов) редуцируются перед
отправкой.
Типы событий (event)
События PayIn/PayOut используют префикс deal.* (мерчантский вебхук) или
p2p.deal.* (внутренняя Kafka). События вывода USDT — отдельное имя.
События PayIn (OrderStatus transitions)
| Имя события | Статус сделки | Когда отправляется |
|---|---|---|
deal.created | INITIALIZED | PayIn-сделка создана. |
deal.escrow_locked | ESCROW_LOCKED | Эскроу-холд зарезервирован, реквизиты выданы. |
deal.payment_notified | PAYMENT_NOTIFIED | Клиент нажал «Я оплатил». |
deal.amount_changed | (без смены статуса) | Оператор изменил сумму живой сделки (до PAYMENT_VERIFIED). Payload несёт old_amount, новую amount и amount_version. Только V2 — см. примечание ниже. |
deal.payment_verified | PAYMENT_VERIFIED | Поступление средств подтверждено, сумма сверена. |
deal.completed | COMPLETED | PayIn финально завершён успешно. |
deal.expired | EXPIRED | Сделка истекла по payment_timeout_at. |
deal.appealed | APPEALED | По сделке открыта апелляция. |
deal.refund_pending | REFUND_PENDING | Инициирован возврат средств. |
deal.cancelled | CANCELLED | Игрок или мерч явно отменили сделку до подтверждения платежа. |
deal.amount_changed доступно только мерчантам V2: legacy-конверт V1
не имеет callbackType под это событие (маппер знает только коды статусов
сделок 1/2/11/12/13/14/15/16/21), поэтому V1-мерчантам оно не доставляется
— как и события каталога. Это контрактное поведение, а не пропуск.
Settlement-сделка (зачёт просроченного платежа) эмитит обычный набор
событий deal.created … deal.completed — эти события доступны обоим
версиям API, различить сеттлмент можно по полям parent_deal_id /
origin_appeal_id в объекте сделки.
События PayOut (PayOutStatus transitions)
PayOut-события используют те же имена deal.completed / deal.failed,
что и PayIn (НЕ payout.completed). Это намеренно: PayOut — это тоже
«сделка» в терминах вебхука. Различить направление можно по полю status
(PayOutStatus vs OrderStatus) или по отсутствию полей
received_amount / amount_match_status (они есть только у PayIn).
| Имя события | Статус выплаты | Когда отправляется |
|---|---|---|
deal.created | INITIALIZED | PayOut создан, баланс захолдирован. |
deal.completed | COMPLETED | PayOut успешно исполнён — средства отправлены получателю. |
deal.failed | FAILED | PayOut отклонён (неверные реквизиты, отмена трейдером). Холд возвращён мерчанту. |
deal.expired | EXPIRED | PayOut истёк до завершения перевода. |
События вывода USDT (WithdrawalStatus)
| Имя события | Статус | Когда отправляется |
|---|---|---|
p2p.finance.withdrawal_completed | APPROVED | On-chain tx отправлена, ledger списан. |
p2p.finance.withdrawal_failed | CANCELLED | Вывод отклонён (admin / pre-broadcast cancel). |
В Kafka эти же переходы публикуются с префиксом p2p.deal.* для
внутренних потребителей. Мерчантские вебхуки V2 — deal.* без префикса;
вывод средств — p2p.finance.withdrawal_*.
События каталога платёжных методов
| Имя события | Когда отправляется |
|---|---|
payment_methods.updated | Администратор создал или изменил каталог-видимые поля метода оплаты (name, status, requisite_types, is_cross_border). Рассылка всем мерчантам тенанта. |
payment_methods.updated — только V2 (api_version = v2). Legacy-конверт
V1 не имеет типа уведомления для событий каталога (callbackType 1/2/3/10),
поэтому V1-мерчантам событие не отправляется: доставка только активным V2
мерчантам с настроенным непустым webhook_url.
Обновление со идентичными значениями (no-op) НЕ эмитится.
Конверт каталог-события — отдельная структура (НЕ CallbackEventPayload):
поля amount / currency / deal_id в нём отсутствуют — событие
описывает изменение справочника, а не транзакцию:
{
"event": "payment_methods.updated",
"merchant_id": "8f870a31-b88d-4cb0-a882-628d08cbda88",
"tenant_id": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": "2026-08-27T12:00:00Z",
"data": {
"changed": [
{
"payment_method_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"slug": "sbp_rub",
"status": "DISABLED",
"name": "SBP",
"before": {
"status": "ACTIVE",
"name": "SBP",
"requisite_types": ["SBP"]
}
}
]
}
}Элемент data.changed[] несёт AFTER-состояние метода
(payment_method_id, slug, status, name); при обновлении дополнительно
before — снапшот каталог-видимых полей до изменения (status, name,
requisite_types, is_cross_border). При создании метода before
отсутствует.
Событие — нотификация, а не источник данных. Рекомендация: по
payment_methods.updated перечитать актуальный каталог через
GET /api/v1/p2p/merchant/payment-methods
и закэшировать до следующего события. Не стройте локальную копию каталога
только из payload — полный список методов событие не содержит.
Доставка и подпись — те же, что у lifecycle-событий: X-Signature
(HMAC-SHA256 от {t}.{raw_body} ключом webhook_secret мерчанта),
повторы по лестнице выше. Запись доставки в истории (GET /api/v1/p2p/merchant/callbacks) не привязана ни к сделке, ни к выплате
(соответствующие поля NULL).
Примеры payload по событиям
deal.amount_changed — оператор изменил сумму живой сделки (до
верификации платежа). amount несёт НОВУЮ сумму, old_amount — прежнюю,
amount_version — номер правки (монотонный, стартует с 1 у новой сделки):
эскроу-холды пересозданы под новую сумму, USDT-эквивалент пересчитан по
курсу самой сделки. Статус сделки при этом не меняется:
{
"event": "deal.amount_changed",
"deal_id": "7ac148fe-19a3-45bb-b992-019fac55b721",
"merchant_id": "8f870a31-b88c-4cb0-a882-628d08cbda88",
"tenant_id": "550e8400-e29b-41d4-a716-446655440000",
"amount": 200000,
"old_amount": 150000,
"amount_version": 2,
"currency": "RUB",
"status": "PAYMENT_NOTIFIED",
"timestamp": "2026-09-03T18:12:44Z"
}deal.payment_verified — поступление средств подтверждено (сверка сумм):
{
"event": "deal.payment_verified",
"deal_id": "7ac148fe-19a3-45bb-b992-019fac55b721",
"merchant_id": "8f870a31-b88d-4cb0-a882-628d08cbda88",
"tenant_id": "550e8400-e29b-41d4-a716-446655440000",
"amount": 250050,
"currency": "RUB",
"status": "PAYMENT_VERIFIED",
"timestamp": "2026-07-12T17:23:45Z",
"received_amount": 250050,
"amount_match_status": "full"
}deal.completed (PayIn) — успешное завершение входящего платежа:
{
"event": "deal.completed",
"deal_id": "7ac148fe-19a3-45bb-b992-019fac55b721",
"merchant_id": "8f870a31-b88d-4cb0-a882-628d08cbda88",
"tenant_id": "550e8400-e29b-41d4-a716-446655440000",
"amount": 250050,
"currency": "RUB",
"status": "COMPLETED",
"timestamp": "2026-07-12T17:24:12Z",
"received_amount": 250050,
"amount_match_status": "full"
}deal.expired (PayIn) — сделка истекла по payment_timeout_at, средства
не поступили (терминальный неуспешный исход входящего платежа). Поля
received_amount и amount_match_status отсутствуют — сверка сумм не
выполнялась:
{
"event": "deal.expired",
"deal_id": "7ac148fe-19a3-45bb-b992-019fac55b721",
"merchant_id": "8f870a31-b88d-4cb0-a882-628d08cbda88",
"tenant_id": "550e8400-e29b-41d4-a716-446655440000",
"amount": 250050,
"currency": "RUB",
"status": "EXPIRED",
"timestamp": "2026-07-12T17:55:00Z"
}deal.cancelled (PayIn) — сделка явно отменена (игроком на платёжной
странице или мерчантом через cancel-API) до подтверждения платежа. Как и у
deal.expired, терминальный неуспешный исход: поля received_amount и
amount_match_status отсутствуют — сверка сумм не выполнялась:
{
"event": "deal.cancelled",
"deal_id": "7ac148fe-19a3-45bb-b992-019fac55b721",
"merchant_id": "8f870a31-b88d-4cb0-a882-628d08cbda88",
"tenant_id": "550e8400-e29b-41d4-a716-446655440000",
"amount": 250050,
"currency": "RUB",
"status": "CANCELLED",
"timestamp": "2026-07-12T17:30:00Z"
}Если ваш обработчик фильтрует события по имени (например, реагирует только
на deal.completed), добавьте deal.cancelled в фильтр — иначе отмена
пройдёт мимо вашей логики, а сделка «зависнет» в ожидании финала.
deal.completed (PayOut) — успешное завершение выплаты (средства
отправлены получателю). Обратите внимание: received_amount и
amount_match_status отсутствуют — это поля только для PayIn:
{
"event": "deal.completed",
"deal_id": "e81d77a0-0d3a-4422-b91c-7cb052a2119c",
"merchant_id": "8f870a31-b88d-4cb0-a882-628d08cbda88",
"tenant_id": "550e8400-e29b-41d4-a716-446655440000",
"amount": 500000,
"currency": "RUB",
"status": "COMPLETED",
"timestamp": "2026-07-12T17:28:00Z"
}deal.failed (PayOut) — выплата отклонена, холд возвращён:
{
"event": "deal.failed",
"deal_id": "e81d77a0-0d3a-4422-b91c-7cb052a2119c",
"merchant_id": "8f870a31-b88d-4cb0-a882-628d08cbda88",
"tenant_id": "550e8400-e29b-41d4-a716-446655440000",
"amount": 500000,
"currency": "RUB",
"status": "FAILED",
"timestamp": "2026-07-12T17:29:30Z"
}p2p.finance.withdrawal_completed — криптовалютный вывод USDT исполнен:
{
"event": "p2p.finance.withdrawal_completed",
"deal_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"merchant_id": "8f870a31-b88d-4cb0-a882-628d08cbda88",
"tenant_id": "550e8400-e29b-41d4-a716-446655440000",
"amount": 1000000,
"currency": "USDT",
"status": "APPROVED",
"timestamp": "2026-07-12T17:30:05Z"
}Для withdrawal-событий поле deal_id содержит идентификатор
withdrawal-записи (заявки на вывод USDT), а не Order.ID — вывод
не является ордером.
Верификация подписи V2
const crypto = require('crypto');
function verifyV2Webhook(rawBody, signatureHeader, webhookSecret) {
// 1. Парсинг заголовка X-Signature: t=<unix>,v1=<hex>
const parts = signatureHeader.split(',');
const timestampPart = parts.find(p => p.startsWith('t='));
const signaturePart = parts.find(p => p.startsWith('v1='));
if (!timestampPart || !signaturePart) return false;
const timestamp = timestampPart.substring(2);
const providedSignature = signaturePart.substring(3);
// 2. Защита от replay-атак: метка не старше 5 минут
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp, 10)) > 300) {
return false;
}
// 3. Вычисление ожидаемой сигнатуры: HMAC-SHA256("{t}.{body}", secret)
const payload = `${timestamp}.${rawBody}`;
const expectedSignature = crypto
.createHmac('sha256', webhookSecret)
.update(payload)
.digest('hex');
// 4. Безопасное сравнение (timing-safe)
return crypto.timingSafeEqual(
Buffer.from(providedSignature, 'utf-8'),
Buffer.from(expectedSignature, 'utf-8')
);
}Подпись вычисляется по сырому телу запроса (raw bytes), как оно пришло из сети. Не декодируйте/нормализуйте JSON перед верификацией — это сломает подпись.
Типы уведомлений в адаптере (V1 Compatibility)
Для legacy-мерчантов с api_version=v1 callback-service строит V1-конверт:
{
"callbackType": 1,
"callbackData": {
"paymentId": "7ac148fe-19a3-45bb-b992-019fac55b721",
"payInId": "order_12345",
"clientId": "user_99",
"status": 11,
"currency": 643,
"amount": "2500.50",
"paidAmount": 2500.50,
"cardNumber": "**** **** **** 1234",
"fullName": "Иван И.",
"callbackUrl": "https://example.com/webhook",
"createdAt": "2026-07-12T17:24:12Z"
},
"salt": 4729103
}Маппинг типов уведомлений V1 (callbackType)
callbackType | Сущность | Соответствие V2 |
|---|---|---|
1 | Входящий платеж (PayIn) | deal.* с OrderStatus |
2 | Исходящий платеж (PayOut) | deal.completed / deal.failed с PayOutStatus |
3 | Открытие/изменение спора | deal.appealed |
10 | Криптовывод USDT | p2p.finance.withdrawal_* |
V1 spec: NO RETRIES. Для api_version=v1 любая неудача доставки
терминальна — повторных попыток нет. Экспоненциальный backoff ниже
применяется только к V2 мерчантам.
Правила обработки вебхуков (Best Practices)
- Возвращайте HTTP 200 быстро. Обработчик должен сразу вернуть
200 OKс телом{"status":"success"}. Тяжёлую бизнес-логику выносите в фоновую очередь — сначала сохраните событие, ответьте200, затем обрабатывайте асинхронно. - Идемпотентность. Всегда проверяйте, не обрабатывали ли вы уже событие
с данным
deal_id+event+timestamp. Дубли возможны из-за ретраев. - Игнорируйте неизвестные типы событий. Список
eventрасширяется со временем (новые статусы, новые сущности) — ваш обработчик должен сохранять/логировать неизвестныйeventи отвечать200, а не падать или возвращать ошибку. Отвечать ошибкой имеет смысл только когда вы действительно хотите повторную доставку. - Retry Policy (V2). Автоматическая лестница повторов описана выше
(раздел История доставок); терминальный
ERRORможно пере-поставить в очередь ручным retry. - IP Whitelisting. Для дополнительной защиты можно ограничить приём вебхуков списком IP-адресов шлюза Syncra (актуальный список — в панели управления).