Вебхуки в Laravel: почему платежи теряются и как это чинить
Провайдер отправил уведомление об оплате, а заказ так и не подтвердился. Разбираем, где Laravel-приложение теряет вебхуки под нагрузкой и как построить приём, который переживёт ретраи и дубли.

Магазин на Laravel 11 принимает платежи через внешний провайдер. Провайдер шлёт вебхук об успешной оплате, контроллер обновляет заказ, покупатель видит подтверждение. В логах провайдера — 200 OK на каждое уведомление. В базе — 4% заказов с оплатой, которая прошла у эквайера, но не отметилась в системе. Клиенты пишут в поддержку, деньги списаны, товар не отгружен. Разбираем, где именно приложение теряет данные и как перестроить приём вебхуков.
Где теряются вебхуки
Наивный обработчик выглядит так: контроллер принимает запрос, парсит тело, лезет в базу, шлёт письмо, возвращает 200. Проблема в том, что всё это происходит внутри одного HTTP-запроса от провайдера. И у такого подхода четыре точки отказа.
- Таймаут провайдера. Отправка письма или обращение к внешнему API внутри обработчика растягивает ответ на секунды. Многие платёжные системы ждут ответ 5–10 секунд, потом считают доставку неуспешной и уходят в ретрай. Ваш код при этом отработал наполовину.
- Дубли из-за ретраев. Провайдер повторяет доставку, если не получил быстрый 200. Если обработчик не идемпотентен, вы дважды спишете бонусы, дважды отгрузите, дважды отправите письмо.
- Гонки. Два уведомления по одному заказу приходят почти одновременно (оплата и возврат части суммы). Оба читают старое состояние заказа, оба пишут своё — одно затирает другое.
- Падение внутри транзакции. Обновили заказ, начали слать письмо, упал SMTP — исключение, заказ в базе уже обновлён, но клиент считает запрос неуспешным.
Принцип: принять быстро, обработать отдельно
Рабочий приём вебхука состоит из двух фаз. Первая — синхронная и короткая: проверить подпись, сохранить сырое тело запроса, вернуть 200. Вторая — асинхронная: очередь берёт сохранённую запись и делает всю бизнес-логику. Провайдер получает быстрый ответ и не уходит в ретрай, а тяжёлая работа изолирована от HTTP-запроса.
Контроллер вебхука не должен обновлять заказы. Его единственная задача — убедиться, что запрос настоящий, положить его в надёжное хранилище и подтвердить приём. Всё остальное делает воркер.
Фаза 1: приём и проверка подписи
Подпись проверяют по сырому телу запроса, а не по разобранному массиву — иначе перестановка ключей или пробелы сломают сравнение. В Laravel сырое тело доступно через $request->getContent().
<?php
// app/Http/Controllers/PaymentWebhookController.php — Laravel 11
namespace App\Http\Controllers;
use App\Models\WebhookEvent;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class PaymentWebhookController extends Controller
{
public function handle(Request $request)
{
$payload = $request->getContent();
$signature = $request->header('X-Provider-Signature', '');
// hash_equals защищает от атаки по времени сравнения
$expected = hash_hmac('sha256', $payload, config('services.provider.secret'));
if (! hash_equals($expected, $signature)) {
Log::warning('Неверная подпись вебхука');
return response()->json(['error' => 'invalid signature'], 401);
}
$data = json_decode($payload, true);
$eventId = $data['event_id'] ?? null;
if (! $eventId) {
return response()->json(['error' => 'no event id'], 422);
}
// Идемпотентность: уникальный индекс на event_id отсекает дубли
$event = WebhookEvent::firstOrCreate(
['event_id' => $eventId],
['payload' => $payload, 'status' => 'pending']
);
if ($event->wasRecentlyCreated) {
ProcessPaymentWebhook::dispatch($event->id);
}
// Всегда 200, даже на повторе — иначе провайдер продолжит ретраить
return response()->json(['ok' => true]);
}
}
Ключевая деталь — firstOrCreate по полю с уникальным индексом. Если провайдер прислал тот же event_id второй раз, запись не создастся заново, задача в очередь не уйдёт, а ответ всё равно будет 200. Именно уникальный индекс в БД, а не проверка «а есть ли такая запись» отдельным запросом — иначе два одновременных ретрая проскочат мимо проверки и создадут дубль.
Фаза 2: обработка в очереди
<?php
// app/Jobs/ProcessPaymentWebhook.php — Laravel 11
namespace App\Jobs;
use App\Models\Order;
use App\Models\WebhookEvent;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
class ProcessPaymentWebhook implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 5;
public array $backoff = [10, 30, 60, 120]; // секунды между попытками
public function __construct(public int $eventId) {}
public function handle(): void
{
$event = WebhookEvent::findOrFail($this->eventId);
if ($event->status === 'processed') {
return; // уже обработали на прошлой попытке
}
$data = json_decode($event->payload, true);
// Блокировка строки заказа снимает гонку между двумя событиями
DB::transaction(function () use ($event, $data) {
$order = Order::where('id', $data['order_id'])
->lockForUpdate()
->firstOrFail();
if ($order->status !== 'paid') {
$order->status = 'paid';
$order->paid_at = now();
$order->save();
}
$event->status = 'processed';
$event->save();
});
// Письмо — вне транзакции: падение SMTP не должно откатывать оплату
// (отправку тоже стоит вынести в отдельную задачу)
}
}
Здесь работают три защиты сразу: проверка статуса события в начале, lockForUpdate внутри транзакции против гонок и обновление заказа только если он ещё не оплачен. Отправку письма вынесли за пределы транзакции — иначе откат из-за упавшего SMTP снимет уже подтверждённую оплату.
Сравнение подходов к приёму
| Подход | Дубли | Таймауты | Гонки | Когда допустим |
|---|---|---|---|---|
| Всё синхронно в контроллере | Не защищён | Частые | Не защищён | Прототип, единичные события |
| Синхронно + проверка «есть ли запись» | Протекает при гонке | Частые | Не защищён | Низкая нагрузка |
| Приём + очередь + уникальный индекс + lock | Отсекаются | Нет | Отсекаются | Боевой приём платежей |
Что ломается на живом проекте
После перехода на схему «приём + очередь» появляются новые классы проблем — уже не в контроллере, а вокруг него.
- Очередь стоит. Вы принимаете вебхуки и отвечаете 200, но воркер
queue:workне запущен или упал. Провайдер доволен, заказы висят вpending. Нужен мониторинг длины очереди и статуса Supervisor, а не только логов приёма. - Задача улетела в failed_jobs. После
$triesпопыток задача уходит в таблицу неудачных. Без алерта на её рост вы узнаёте о проблеме от клиента. Настройте уведомление и методfailed()в задаче, который переведёт событие в статусfailedдля ручного разбора. - Событие пришло раньше, чем создан заказ. Некоторые провайдеры шлют уведомление быстрее, чем ваш редирект-хендлер успел создать запись.
firstOrFailкинет исключение, задача уйдёт в ретрай — и это правильное поведение: backoff даст заказу появиться. - Проверка подписи по разобранному телу. Если считать HMAC от
$request->all(), а не отgetContent(), подпись начнёт разъезжаться на нетипичных значениях. Всегда берите сырое тело. - Middleware съел тело. Кастомный middleware, который читает и модифицирует запрос, может исказить сырое тело до контроллера. Проверяйте подпись как можно раньше в цепочке.
Мониторинг: три вопроса к системе
- Сколько событий в статусе
pendingстарше пяти минут? Растёт — очередь не разгребает. - Сколько записей в
failed_jobsза час? Не ноль — что-то системно ломается в обработке. - Есть ли расхождение между оплатами у эквайера и заказами в статусе
paid? Это финальная проверка, которую стоит гонять сверкой раз в сутки.
Что настроить дальше
Разберите инвалидацию и повторную обработку зависших событий — это отдельная тема, здесь мы её не разворачивали. Из ближайших шагов: поднимите Supervisor для queue:work с автоперезапуском, добавьте команду по расписанию queue:retry для событий в failed, и настройте суточную сверку с выгрузкой провайдера. Логируйте event_id на каждом переходе статуса — при разборе инцидента это единственная нить, по которой вы восстановите путь конкретного платежа.
Разрушитель авто-разводов: Фото заказ-наряда СТО: завышения, дубли работ, лишние позиции.
Спросить за 15 ₽Источники: Laravel 11 — Queues, Laravel 11 — Database: Transactions
Частые вопросы
А если у меня Laravel 10, а не 11?
Что делать, если 200 уходит, а заказы всё равно не обновляются?
Зачем сохранять сырое тело запроса, если можно работать с массивом?
Как гарантировать, что дубль вебхука не спишет бонусы дважды?
Провайдер не присылает event_id — на чём тогда строить идемпотентность?
Нужно ли проверять IP-адрес отправителя вебхука?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.