Настройка Webhook-системы с логированием и повторной отправкой

Наша компания занимается разработкой, поддержкой и обслуживанием сайтов любой сложности. От простых одностраничных сайтов до масштабных кластерных систем построенных на микро сервисах. Опыт разработчиков подтвержден сертификатами от вендоров.
Разработка и обслуживание любых видов сайтов:
Информационные сайты или веб-приложения
Сайты визитки, landing page, корпоративные сайты, онлайн каталоги, квиз, промо-сайты, блоги, новостные ресурсы, информационные порталы, форумы, агрегаторы
Сайты или веб-приложения электронной коммерции
Интернет-магазины, B2B-порталы, маркетплейсы, онлайн-обменники, кэшбэк-сайты, биржи, дропшиппинг-платформы, парсеры товаров
Веб-приложения для управления бизнес-процессами
CRM-системы, ERP-системы, корпоративные порталы, системы управления производством, парсеры информации
Сайты или веб-приложения электронных услуг
Доски объявлений, онлайн-школы, онлайн-кинотеатры, конструкторы сайтов, порталы предоставления электронных услуг, видеохостинги, тематические порталы

Это лишь некоторые из технических типов сайтов, с которыми мы работаем, и каждый из них может иметь свои специфические особенности и функциональность, а также быть адаптированным под конкретные потребности и цели клиента

Предлагаемые услуги
Показано 1 из 1 услугВсе 2065 услуг
Настройка Webhook-системы с логированием и повторной отправкой
Средняя
~2-3 рабочих дня
Часто задаваемые вопросы
Наши компетенции:
Этапы разработки
Последние работы
  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1214
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1161
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    852
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1041
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    823
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Разработка веб-сайта для компании ФИКСПЕР
    815

Настройка Webhook-системы с логированием и повторной отправкой

Webhook — это исходящий HTTP-запрос, который вы не контролируете до конца. Получатель может вернуть 200, не обработав данные. Может упасть через 9 секунд после получения. Может пропустить событие без следа. Без детального логирования всех попыток и возможности ручной повторной отправки отладить проблемы с интеграцией практически невозможно.

Что логировать по каждой попытке

Минимальный набор данных на каждую попытку доставки:

Поле Описание
delivery_id UUID доставки — связывает все попытки
attempt_number Номер попытки (1, 2, 3...)
started_at Время начала попытки
duration_ms Сколько заняло — важно для детектирования таймаутов
request_headers Заголовки запроса (без секрета в чистом виде)
request_body Тело запроса (payload события)
response_code HTTP-статус ответа
response_headers Заголовки ответа
response_body Первые 2 КБ тела ответа — для дебага
error Текст ошибки при ConnectionException / Timeout

Схема таблицы попыток

CREATE TABLE webhook_attempts (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  delivery_id UUID NOT NULL REFERENCES webhook_deliveries(id) ON DELETE CASCADE,
  attempt_number INTEGER NOT NULL,
  started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  duration_ms INTEGER,
  request_body JSONB,
  request_headers JSONB,
  response_code INTEGER,
  response_headers JSONB,
  response_body TEXT,           -- обрезается до 2000 символов
  error_message TEXT,
  success BOOLEAN NOT NULL DEFAULT false
);

CREATE INDEX idx_attempts_delivery ON webhook_attempts(delivery_id);
CREATE INDEX idx_attempts_started ON webhook_attempts(started_at DESC);

Храним попытки отдельно от доставок — одна доставка может иметь 8 попыток. Это позволяет видеть полную историю и понимать, на каком шаге всё пошло не так.

Реализация логирования

class WebhookAttemptLogger
{
    public function log(
        WebhookDelivery $delivery,
        int $attempt,
        WebhookAttemptData $data
    ): WebhookAttempt {
        return WebhookAttempt::create([
            'delivery_id'      => $delivery->id,
            'attempt_number'   => $attempt,
            'started_at'       => $data->startedAt,
            'duration_ms'      => $data->durationMs,
            'request_body'     => $delivery->payload,
            'request_headers'  => $data->requestHeaders,
            'response_code'    => $data->responseCode,
            'response_headers' => $data->responseHeaders,
            'response_body'    => $data->responseBody
                ? mb_substr($data->responseBody, 0, 2000)
                : null,
            'error_message'    => $data->errorMessage,
            'success'          => $data->success,
        ]);
    }
}

class SendWebhookJob implements ShouldQueue
{
    public function handle(
        WebhookAttemptLogger $logger
    ): void {
        $startedAt = now();
        $requestHeaders = $this->buildHeaders();

        try {
            $response = Http::timeout(15)
                ->withHeaders($requestHeaders)
                ->post($this->delivery->subscription->endpoint_url, $this->delivery->payload);

            $durationMs = (int)(microtime(true) * 1000 - $startedAt->timestamp * 1000);

            $logger->log($this->delivery, $this->delivery->attempt_count, new WebhookAttemptData(
                startedAt: $startedAt,
                durationMs: $durationMs,
                requestHeaders: $requestHeaders,
                responseCode: $response->status(),
                responseHeaders: $response->headers(),
                responseBody: $response->body(),
                success: $response->successful(),
            ));

            if ($response->successful()) {
                $this->delivery->markDelivered();
            } else {
                $this->delivery->scheduleRetry();
            }

        } catch (\Throwable $e) {
            $durationMs = (int)(microtime(true) * 1000 - $startedAt->timestamp * 1000);

            $logger->log($this->delivery, $this->delivery->attempt_count, new WebhookAttemptData(
                startedAt: $startedAt,
                durationMs: $durationMs,
                requestHeaders: $requestHeaders,
                errorMessage: get_class($e) . ': ' . $e->getMessage(),
                success: false,
            ));

            $this->delivery->scheduleRetry();
        }
    }
}

Ручная повторная отправка

Администратор или разработчик должны уметь переотправить любое событие без изменения кода. Это критично при отладке интеграций и восстановлении после сбоев.

class WebhookDeliveryController extends Controller
{
    // Повторить конкретную доставку
    public function resend(WebhookDelivery $delivery): JsonResponse
    {
        abort_if(
            $delivery->status === 'delivered',
            422,
            'Delivery already succeeded'
        );

        $delivery->update([
            'status'          => 'pending',
            'attempt_count'   => 0,
            'next_attempt_at' => now(),
        ]);

        SendWebhookJob::dispatch($delivery);

        return response()->json(['queued' => true]);
    }

    // Повторить все упавшие доставки подписки
    public function resendFailed(WebhookSubscription $subscription): JsonResponse
    {
        $count = WebhookDelivery::where('subscription_id', $subscription->id)
            ->where('status', 'failed')
            ->count();

        WebhookDelivery::where('subscription_id', $subscription->id)
            ->where('status', 'failed')
            ->update([
                'status'          => 'pending',
                'attempt_count'   => 0,
                'next_attempt_at' => now(),
            ]);

        WebhookDelivery::where('subscription_id', $subscription->id)
            ->where('status', 'pending')
            ->each(fn($d) => SendWebhookJob::dispatch($d));

        return response()->json(['requeued' => $count]);
    }

    // История попыток для конкретной доставки
    public function attempts(WebhookDelivery $delivery): JsonResponse
    {
        return response()->json(
            $delivery->attempts()
                ->orderBy('attempt_number')
                ->get(['attempt_number', 'started_at', 'duration_ms',
                       'response_code', 'response_body', 'error_message', 'success'])
        );
    }
}

Дашборд доставок

Для операционного мониторинга нужен интерфейс с фильтрацией по:

  • Статусу (pending, delivered, failed)
  • Типу события
  • Подписке / потребителю
  • Временному диапазону

Полезные агрегаты в PostgreSQL:

-- Статистика за последние 24 часа
SELECT
  event_type,
  COUNT(*) FILTER (WHERE status = 'delivered') AS delivered,
  COUNT(*) FILTER (WHERE status = 'failed')    AS failed,
  COUNT(*) FILTER (WHERE status = 'pending')   AS pending,
  ROUND(AVG(attempt_count), 2)                 AS avg_attempts,
  PERCENTILE_CONT(0.95) WITHIN GROUP (
    ORDER BY EXTRACT(EPOCH FROM (delivered_at - created_at))
  ) AS p95_delivery_seconds
FROM webhook_deliveries
WHERE created_at > NOW() - INTERVAL '24 hours'
GROUP BY event_type
ORDER BY failed DESC;

Срок хранения логов

Попытки доставки занимают место. Стратегия ротации:

  • Успешные попытки — хранить 30 дней, потом удалять тело запроса, оставлять метаданные
  • Неуспешные — хранить 90 дней (нужны для аудита интеграций)
  • Тело ответа с ошибкой — максимум 2 КБ, не хранить бинарные данные

Сроки

Система логирования попыток + ручная повторная отправка: 3–5 дней. С дашбордом, агрегатами, фильтрацией и retention policy: 1–1.5 недели.