Налаштування Webhook-системи з управлінням підписками (subscribe/unsubscribe)

Наша компанія займається розробкою, підтримкою та обслуговуванням сайтів будь-якої складності. Від простих односторінкових сайтів до масштабних кластерних систем, побудованих на мікро сервісах. Досвід розробників підтверджено сертифікатами від вендорів.

Розробка та обслуговування будь-яких видів сайтів:

Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Налаштування Webhook-системи з управлінням підписками (subscribe/unsubscribe)
Середня
~2-3 робочих дні
Часті питання

Наші компетенції:

Етапи розробки
Останні роботи
  • image_website-b2b-advance_0.png
    Розробка сайту компанії B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    874
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Розробка веб-сайту для компанії ФІКСПЕР
    851

Налаштування 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 тижня.