Реализация Webhook-системы для интеграций сайта
Webhook — это HTTP-колбэк: внешний сервис отправляет POST-запрос на указанный URL в момент наступления события. Принципиально отличается от polling-подхода: не сайт периодически спрашивает «есть ли новые данные?», а источник сам уведомляет при изменении. Webhook-система на сайте может работать в двух ролях: принимать входящие события от внешних сервисов и отправлять исходящие события для сторонних подписчиков.
Входящие webhooks: приём и обработка
Базовая архитектура для приёма событий от внешних систем (платёжные шлюзы, CRM, маркетплейсы):
// routes/api.php
Route::post('/webhooks/{provider}', WebhookController::class);
class WebhookController extends Controller
{
public function __invoke(Request $request, string $provider): JsonResponse
{
// 1. Логируем сырой запрос до любой обработки
WebhookLog::create([
'provider' => $provider,
'headers' => $request->headers->all(),
'payload' => $request->getContent(),
'ip' => $request->ip(),
'received_at' => now(),
]);
// 2. Верифицируем подпись
$handler = WebhookHandlerFactory::make($provider);
if (!$handler->verify($request)) {
return response()->json(['error' => 'Invalid signature'], 401);
}
// 3. Ставим в очередь — не обрабатываем синхронно
ProcessWebhookJob::dispatch($provider, $request->all())
->onQueue('webhooks');
// 4. Отвечаем быстро — внешний сервис ждёт 200 OK
return response()->json(['ok' => true]);
}
}
Критичный момент: ответ должен уйти в течение 3–10 секунд (лимит у большинства провайдеров). Любая тяжёлая логика — в очередь.
Верификация подписей
Каждый провайдер подписывает payload по-своему. Несколько примеров:
Stripe / HMAC-SHA256:
$secret = config('services.stripe.webhook_secret');
$sigHeader = $request->header('Stripe-Signature');
$payload = $request->getContent();
// Stripe использует timestamp + HMAC
[$t, $v1] = $this->parseStripeSignature($sigHeader);
$signed = hash_hmac('sha256', "{$t}.{$payload}", $secret);
if (!hash_equals($signed, $v1)) {
throw new InvalidSignatureException();
}
GitHub / SHA-256 с префиксом:
$secret = config('services.github.webhook_secret');
$signature = $request->header('X-Hub-Signature-256');
$expected = 'sha256=' . hash_hmac('sha256', $request->getContent(), $secret);
if (!hash_equals($expected, $signature)) {
abort(401);
}
Простой токен в заголовке (многие CRM):
$token = $request->header('X-Webhook-Token');
if (!hash_equals(config('services.crm.webhook_token'), $token)) {
abort(403);
}
Важно всегда использовать hash_equals вместо === — защита от тайминг-атак.
Идемпотентность и дедупликация
Внешние сервисы могут прислать одно событие несколько раз: при таймауте, после перезапуска, по политике retry. Нужна дедупликация:
class ProcessWebhookJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue;
public string $uniqueFor = 3600; // уникальность в течение часа
public function handle(): void
{
$eventId = $this->payload['id'] ?? $this->payload['event_id'] ?? null;
if ($eventId && WebhookEvent::where('external_id', $eventId)->exists()) {
Log::info("Webhook duplicate skipped: {$eventId}");
return;
}
// Сохраняем event_id до обработки
WebhookEvent::create(['external_id' => $eventId, 'processed_at' => now()]);
// Основная логика
$this->process();
}
}
Исходящие webhooks: отправка событий подписчикам
Если сайт сам является источником событий (например, интернет-магазин уведомляет партнёров об изменении заказов), нужна система управления подписками:
// Таблица подписчиков
Schema::create('webhook_subscriptions', function (Blueprint $table) {
$table->id();
$table->string('url');
$table->string('event'); // 'order.created', 'order.status_changed'
$table->string('secret'); // для HMAC-подписи
$table->boolean('active')->default(true);
$table->integer('failures')->default(0);
$table->timestamp('last_error_at')->nullable();
$table->timestamps();
});
Отправка события всем подписчикам:
class WebhookDispatcher
{
public function dispatch(string $event, array $payload): void
{
$subscriptions = WebhookSubscription::active()
->where('event', $event)
->get();
foreach ($subscriptions as $subscription) {
SendWebhookJob::dispatch($subscription, $payload)
->onQueue('webhooks-outgoing');
}
}
}
class SendWebhookJob implements ShouldQueue
{
public int $tries = 5;
public array $backoff = [60, 300, 900, 3600, 10800]; // экспоненциальный backoff
public function handle(): void
{
$body = json_encode($this->payload);
$timestamp = time();
$signature = hash_hmac('sha256', "{$timestamp}.{$body}", $this->subscription->secret);
$response = Http::withHeaders([
'Content-Type' => 'application/json',
'X-Webhook-Event' => $this->payload['event'],
'X-Webhook-Timestamp'=> $timestamp,
'X-Webhook-Signature'=> "sha256={$signature}",
])
->timeout(10)
->post($this->subscription->url, $this->payload);
if (!$response->successful()) {
$this->subscription->increment('failures');
if ($this->subscription->failures >= 10) {
$this->subscription->update(['active' => false]);
// уведомить владельца подписки
}
$this->fail("HTTP {$response->status()}");
} else {
$this->subscription->update(['failures' => 0]);
}
}
}
Дашборд мониторинга
Для отладки нужен интерфейс просмотра входящих/исходящих событий: статус, время, payload, ответ. Минимальный вариант — таблица webhook_logs + простой CRUD-контроллер в админке. Продвинутый — интеграция с Laravel Telescope или отдельная страница со статистикой по провайдерам.
Retry и dead letter queue
Не обработанные после всех попыток задачи должны попадать в отдельную очередь для ручного разбора:
// queue.php
'connections' => [
'redis' => [
'driver' => 'redis',
'queue' => 'webhooks',
'retry_after' => 90,
],
],
// В job
public function failed(Throwable $e): void
{
WebhookFailure::create([
'provider' => $this->provider,
'payload' => $this->payload,
'error' => $e->getMessage(),
]);
// алерт в Slack/Telegram
}
Сроки
Базовая система приёма webhooks от одного провайдера с верификацией и очередью: 1 рабочий день. Полноценная платформа с управлением подписками, дашбордом, retry-логикой и мониторингом: 3–5 рабочих дней в зависимости от количества провайдеров и требований к UI.







