Настройка 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 недели.