Реалізація Webhook-системи для інтеграцій сайту
Webhook — це HTTP-колбек: зовнішній сервіс відправляє POST-запит на зазначений URL у момент наступлення події. Принципово відрізняється від polling-підходу: не сайт періодично запитує «є ли нові дані?», а джерело сам сповіщає при зміні. Webhook-система на сайті може працювати в двох ролях: приймати вхідні події від зовнішніх сервісів та відправляти вихідні події для сторонніх підписників.
Вхідні webhook: приймання та обробка
Базова архітектура для приймання подій від зовнішніх систем (платіжні шлюзи, 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. Відповідаємо швидко — зовнішній сервіс чекає 3–10 секунд
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 замість === — захист від timing-атак.
Ідемпотентність та дедупликація
Зовнішні сервіси можуть прислати одну подію кілька разів: при тайм-ауті, після перезапуску, за політикою 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();
}
}
Вихідні webhook: відправка подій підписникам
Якщо сайт сам є джерелом подій (наприклад, інтернет-магазин сповіщає партнерів про зміни замовлень), потрібна система управління підписками:
// Таблиця підписників
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
}
Терміни
Базова система приймання webhook від одного провайдера з верифікацією та очередю: 1 робочий день. Повнофункціональна платформа з управлінням підписками, дашбордом, retry-логікою та мониторингом: 3–5 робочих днів в залежності від кількості провайдерів та вимог до UI.







