Обработка отмен, истечений и ошибок
Не каждый платёж заканчивается успехом — часть клиентов закроет окно, часть не успеет за 15 минут, часть получит отказ банка, а небольшая доля уже оплаченных платежей может «вернуться» с возвратом. Этот раздел — про то, как корректно обработать каждую ветку у себя.
Какие статусы означают неуспех
| Status | Что значит | Деньги списаны? | Что делать |
|---|---|---|---|
cancelled |
Платёж отменён до зачисления (клиент закрыл окно, отменил в банке) | Нет | Просто пометить как failed в своей системе. Дать клиенту создать новый платёж. |
expired |
TTL истёк, клиент не оплатил | Пока нет | Дать клиенту повторить — нужен новый платёж. Этот не терминальный: поздняя оплата может поднять его в paid в течение 6 часов от создания. Закрывай заказ обратимо — см. expired. |
failed |
Платёж не прошёл | Нет | Смотреть failure_reason — от него зависит действие: повторить через день, просить привязать заново или ничего не делать. См. причина отказа. |
refunded |
Платёж был успешен, потом банк-эквайер вернул деньги клиенту | Да, но мы списали обратно с твоего баланса | Критично — пересчитать выдачу товара/услуги клиенту (отозвать доступ или выставить долг). |
amount_mismatch |
Фактическая сумма не совпала с ожиданием | Деньги на удержании | Дождаться разбора админом X-Hub — прилетит следующий webhook с новым статусом (paid или failed). |
refunded ≠ cancelled
cancelled приходит до успеха (деньги не двигались), refunded — после успеха (мы уже зачислили, потом отозвали). Если выдал товар на paid и пришёл refunded — у клиента остаётся товар, но денег ты уже не получишь. Это самый рискованный кейс.
Code example — multi-status handler
Полный обработчик, который покрывает все ветки:
Node.js / TypeScript
function handleXhubWebhook(event) {
if (event.event !== 'payment.status_changed') return;
switch (event.status) {
case 'paid':
case 'settled':
// успешная оплата — выдать товар/услугу
provisionService(event.payment_id, event.external_id);
break;
case 'cancelled':
case 'expired':
// клиент не оплатил — не выдавать, разрешить повтор.
// ВАЖНО: пометка должна быть ОБРАТИМОЙ. `expired` не терминален —
// поздняя оплата поднимет этот же payment_id в `paid` (окно 6 часов),
// и тогда сюда прилетит второй webhook с успехом.
markOrderAsAbandoned(event.external_id);
break;
case 'failed':
// Платёж не прошёл. ЧТО делать — зависит от причины, а не от статуса:
// на одинаковый `failed` бывает и «повтори через день, подписчик
// останется», и «повторы бессмысленны».
switch (event.failure_reason) {
case 'insufficient_funds':
// денег не хватило — подписку НЕ отменяем, пробуем позже
scheduleRetry(event.subscription_id, { inDays: 2 });
break;
case 'mandate_revoked':
// согласие отозвано — повторы не помогут, нужна новая привязка
askCustomerToRebind(event.subscription_id);
break;
case 'expired':
// «срок истёк» у платёжной системы — то же, что наш status:"expired"
markOrderAsAbandoned(event.external_id);
break;
case 'cancelled_by_bank':
// сессию погасил БАНК, срок оплаты ещё не истёк: плательщик ни при
// чём, и «истёк срок» ему говорить нельзя. Совет тот же, что у
// expired, но причина другая — не путать в аналитике.
markOrderAsAbandoned(event.external_id);
break;
case 'cancelled_by_customer':
// осознанный отказ — автоповторов не делаем
markOrderAsAbandoned(event.external_id);
break;
default:
// `declined_by_bank`, `unknown` и любой БУДУЩИЙ код: одна попытка,
// потом человек. Отменять подписку по незнакомому коду нельзя —
// почти всегда ситуация исправима.
notifyClientOfFailure(event.external_id, event.payment_id);
scheduleRetry(event.subscription_id, { inDays: 1, maxAttempts: 1 });
}
break;
case 'refunded':
// CRITICAL: возврат после успеха.
// Если товар уже выдан — отозвать доступ или выставить долг клиенту.
handleRefundAfterFulfillment(event.external_id, event.payment_id);
alertOps('Refund-after-paid: ' + event.payment_id);
break;
case 'amount_mismatch':
// ждать ручного разбора X-Hub — следующий webhook прилетит
// с финальным статусом (paid или failed)
pauseOrderUntilResolved(event.external_id);
break;
default:
// неизвестный статус — лог + 200 (чтобы не словить retry-storm)
logUnknownStatus(event);
}
}
Python
def handle_xhub_webhook(event):
if event['event'] != 'payment.status_changed':
return
status = event['status']
ext_id = event['external_id']
if status in ('paid', 'settled'):
provision_service(event['payment_id'], ext_id)
elif status in ('cancelled', 'expired'):
mark_order_as_abandoned(ext_id)
elif status == 'failed':
notify_client_of_failure(ext_id, event['payment_id'])
elif status == 'refunded':
# CRITICAL: возврат после успеха
handle_refund_after_fulfillment(ext_id, event['payment_id'])
alert_ops(f"Refund-after-paid: {event['payment_id']}")
elif status == 'amount_mismatch':
pause_order_until_resolved(ext_id)
else:
log_unknown_status(event)
PHP
function handleXhubWebhook(array $event): void {
if ($event['event'] !== 'payment.status_changed') return;
$status = $event['status'];
$extId = $event['external_id'];
if ($status === 'paid' || $status === 'settled') {
provisionService($event['payment_id'], $extId);
} elseif ($status === 'cancelled' || $status === 'expired') {
markOrderAsAbandoned($extId);
} elseif ($status === 'failed') {
notifyClientOfFailure($extId, $event['payment_id']);
} elseif ($status === 'refunded') {
// CRITICAL: возврат после успеха
handleRefundAfterFulfillment($extId, $event['payment_id']);
alertOps('Refund-after-paid: ' . $event['payment_id']);
} elseif ($status === 'amount_mismatch') {
pauseOrderUntilResolved($extId);
} else {
logUnknownStatus($event);
}
}
Best practices
- Идемпотентность через
X-Webhook-Id. Тот жеX-Webhook-Id— это retry от X-Hub (мы повторим, если твой endpoint не ответил 200 за 10 сек). ЗапишиX-Webhook-Idв свою БД и не выполняй action дважды для одного и того же id. Подробнее — раздел «Идемпотентность» в Webhooks ниже. - Idempotent action как таковой. Одно и то же событие (например
paidдля одногоpayment_id) может прийти несколько раз —provisionService()должна быть безопасна для повторного вызова (либо сама проверяет «уже выдали», либо UPSERT-операция). - Подпись обязательна. Не доверяй webhook'у безоговорочно — всегда проверяй
X-Webhook-Signature. См. Верификация подписи. -
Финальные статусы — их ровно три. Терминальны только
failed,cancelledиrefunded. Остальное может измениться:expired→paid(поздняя оплата, окно 6 часов от создания) или →amount_mismatch(поздняя оплата не на ту сумму).expiredтерминальным не является — см. expired;paid/settled→refunded(минуты-часы-дни-месяцы спустя);amount_mismatch→paidилиfailedпосле разбора.
Практическое правило: необратимые действия (списать товар со склада, окончательно закрыть заказ) вешай только на
failed/cancelled. Всё остальное должно уметь «переиграться». - Узнавай о сбоях шлюза. Подпишись наgateway.status_changed(см. ниже) — если у X-Hub проблемы, мы сами пришлём webhookstatus: down/degradedсreason: api|completion|both. Используй это для своей системы алертов / мониторинга — не нужно опрашивать/healthв цикле. - Не возвращай 200 ДО обработки. Если упал на DB-вставке — верни 5xx, X-Hub ретайнет. Если вернул 200 — для нас это значит «доставлено», ретая не будет.