Налаштування Webhook-системи з управлінням підписками (subscribe/unsubscribe)
Статично записані webhook-ендпоїнти в конфігу—рішення для MVP. Як тільки з'являється більше двох отримувачів або потрібно дати клієнтам самостійно реєструвати свої ендпоїнти, необхідна система управління підписками через API.
Модель підписки
Кожна підписка пов'язує три речі: споживача (кто отримує), подій (що отримує) та ендпоїнт (куди відправляти).
CREATE TABLE webhook_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
consumer_id UUID NOT NULL REFERENCES consumers(id),
endpoint_url TEXT NOT NULL,
secret TEXT NOT NULL, -- hmac-секрет для верифікації
description TEXT,
events TEXT[] NOT NULL, -- ['order.created', 'order.updated', '*']
is_active BOOLEAN DEFAULT true,
headers JSONB DEFAULT '{}', -- додаткові заголовки для запиту
timeout_seconds INTEGER DEFAULT 10,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
last_delivery_at TIMESTAMPTZ,
failure_count INTEGER DEFAULT 0, -- лічильник послідовних невдач
disabled_at TIMESTAMPTZ -- NULL = активна
);
-- Реєстр допустимих типів подій
CREATE TABLE webhook_event_types (
name TEXT PRIMARY KEY, -- 'order.created'
description TEXT,
example_payload JSONB,
is_active BOOLEAN DEFAULT true
);
Паттерн * в events означає підписку на всі подій—зручно для відлагодження та дашбордів.
API управління підписками
POST /webhooks/subscriptions — створити підписку
GET /webhooks/subscriptions — список підписок споживача
GET /webhooks/subscriptions/{id} — деталі підписки
PATCH /webhooks/subscriptions/{id} — оновити (URL, подій, активність)
DELETE /webhooks/subscriptions/{id} — видалити підписку
POST /webhooks/subscriptions/{id}/test — відправити тестову подію
GET /webhooks/event-types — список доступних типів подій
Створення підписки
class WebhookSubscriptionController extends Controller
{
public function store(StoreSubscriptionRequest $request): JsonResponse
{
$validated = $request->validated();
// ['endpoint_url', 'events', 'description?', 'headers?']
// Валідація подій
$invalidEvents = array_diff(
array_filter($validated['events'], fn($e) => $e !== '*'),
WebhookEventType::where('is_active', true)->pluck('name')->toArray()
);
if (!empty($invalidEvents)) {
return response()->json([
'error' => 'Unknown event types',
'invalid_events' => $invalidEvents,
], 422);
}
// Перевірка доступності ендпоїнту (опціонально)
$reachable = $this->probeEndpoint($validated['endpoint_url']);
$subscription = WebhookSubscription::create([
'consumer_id' => $request->consumer()->id,
'endpoint_url' => $validated['endpoint_url'],
'secret' => Str::random(32),
'events' => $validated['events'],
'description' => $validated['description'] ?? null,
'headers' => $validated['headers'] ?? [],
]);
// Відправити тестову подію при створенні
SendTestWebhookJob::dispatch($subscription);
return response()->json([
'id' => $subscription->id,
'endpoint_url' => $subscription->endpoint_url,
'events' => $subscription->events,
'secret' => $subscription->secret, // показуємо тільки при створенні
'created_at' => $subscription->created_at,
'test_sent' => true,
], 201);
}
Секрет показується клієнту тільки при створенні. Після цього—тільки через ротацію ключа.
Ротація секрету
public function rotateSecret(WebhookSubscription $subscription): JsonResponse
{
$this->authorize('update', $subscription);
$newSecret = Str::random(32);
// Перехідний період: 10 хвилин приймаємо обидва секрети
$subscription->update([
'secret_old' => $subscription->secret,
'secret' => $newSecret,
'secret_rotated_at' => now(),
]);
return response()->json([
'secret' => $newSecret,
'valid_until' => now()->addMinutes(10)->toIso8601String(),
'note' => 'Old secret valid for 10 minutes during rotation',
]);
}
Верифікація з підтримкою перехідного періоду:
public function verifySignature(Request $request, WebhookSubscription $sub): bool
{
$body = $request->getContent();
$signature = $request->header('X-Webhook-Signature');
$expected = 'sha256=' . hash_hmac('sha256', $body, $sub->secret);
if (hash_equals($expected, $signature ?? '')) {
return true;
}
// Перевіряємо старий секрет в перехідному періоді
if ($sub->secret_old && $sub->secret_rotated_at?->gt(now()->subMinutes(10))) {
$expectedOld = 'sha256=' . hash_hmac('sha256', $body, $sub->secret_old);
return hash_equals($expectedOld, $signature ?? '');
}
return false;
}
Автоматичне вимкнення при збоях
Якщо ендпоїнт недоступний кілька днів поспіль, немає сенсу продовжувати спроби і накопичувати чергу:
class WebhookFailureTracker
{
public function recordFailure(WebhookSubscription $subscription): void
{
$subscription->increment('failure_count');
// Вимкнути після 100 послідовних невдач
if ($subscription->failure_count >= 100 && $subscription->is_active) {
$subscription->update([
'is_active' => false,
'disabled_at' => now(),
]);
// Сповістити власника підписки
Notification::send(
$subscription->consumer,
new WebhookSubscriptionDisabled($subscription)
);
}
}
public function recordSuccess(WebhookSubscription $subscription): void
{
$subscription->update([
'failure_count' => 0,
'last_delivery_at' => now(),
]);
}
}
Тестова подія
При створенні підписки і за вимогою—відправляємо webhook.test:
class SendTestWebhookJob implements ShouldQueue
{
public function handle(): void
{
$payload = [
'event' => 'webhook.test',
'id' => Str::uuid(),
'timestamp' => now()->toIso8601String(),
'data' => [
'subscription_id' => $this->subscription->id,
'message' => 'This is a test event. Your webhook is configured correctly.',
],
];
Http::timeout(10)
->withHeaders($this->buildHeaders($payload))
->post($this->subscription->endpoint_url, $payload);
}
}
Dispatch подій на всі підходящі підписки
class WebhookDispatcher
{
public function dispatch(string $eventType, array $payload): void
{
$subscriptions = WebhookSubscription::where('is_active', true)
->where(function ($q) use ($eventType) {
$q->whereJsonContains('events', $eventType)
->orWhereJsonContains('events', '*');
})
->get();
foreach ($subscriptions as $subscription) {
$delivery = WebhookDelivery::create([
'subscription_id' => $subscription->id,
'event_type' => $eventType,
'payload' => $payload,
]);
SendWebhookJob::dispatch($delivery);
}
}
}
// Використання після створення замовлення:
app(WebhookDispatcher::class)->dispatch('order.created', [
'id' => $order->id,
'status' => $order->status,
'total' => $order->total,
'created_at' => $order->created_at,
]);
Терміни
API управління підписками з базовою логікою: 3–4 дні. З ротацією секретів, автовимкненням, тестовими подіями та повною документацією API: 1–1.5 тижня.







