Elido
8 мин чтенияВозможности

Вебхуки для событий ссылок: все форматы, все попытки повтора

Полный функционал вебхуков для событий сокращателя ссылок — форматы полезной нагрузки для click, conversion, link.created и bio.click, а также политика повторных попыток, схема подписи и модель идемпотентности

Marius Voß
DevRel · edge infra
Диаграмма «звезда» с источниками событий ссылок слева (click, conversion, link.created, bio.click), поступающими в центральный сервис webhook-dispatcher, который распределяет их по адресам подписчиков с аннотациями повторных попыток: 1с, 30с, 5м, 1ч, 6ч

Вебхуки — это та часть API сокращателя ссылок, которую внедряют все, но почти никто не делает это хорошо. Сложность заключается не в кодировании — полезная нагрузка представляет собой объект JSON, — а в операционных деталях: проверка подписи, политика повторных попыток, идемпотентность, гарантии доставки и поведение системы, когда конечная точка подписчика недоступна в течение двух дней.

В этой статье задокументировано каждое событие вебхука, которое отправляет Elido, форматы полезной нагрузки, кривая повторных попыток и схема подписи. В быстром старте по API + SDK сокращателя ссылок описана входящая часть API; здесь же рассматривается исходящая.

12 типов событий#

Elido генерирует 12 типов событий вебхуков, сгруппированных в три семейства:

События кликов и трафика: click, bio.click, qr.scan, conversion. Они срабатывают при каждом перенаправлении или сканировании после небольшой задержки в очереди (описано ниже).

События жизненного цикла: link.created, link.updated, link.deleted, bio.published. Они срабатывают на уровне API при изменении соответствующей записи.

События агрегации и операций: daily.summary, campaign.ended, alert.threshold_exceeded, quota.warning. Они срабатывают по расписанию или при превышении порогового значения.

Подписчик регистрирует вебхук через POST /v1/webhooks, указывая целевой URL и массив событий, которые необходимо доставлять. Полный запрос на подписку:

POST /v1/webhooks
Content-Type: application/json
Authorization: Bearer <api-key>

{
  "url": "https://example.com/webhooks/elido",
  "events": ["click", "conversion", "link.created"],
  "secret": "whsec_<32-byte-base64>",
  "active": true
}

secret — это ключ HMAC, используемый для подписи исходящих запросов. Он непрозрачен для Elido; мы никогда не логируем и не отображаем его после ответа на запрос создания.

Формат события click#

По объему это событие, которое интересует вас больше всего. Каждое перенаправление через любую короткую ссылку создает одно событие click после того, как перенаправление было обслужено клиенту. Формат:

{
  "id": "evt_2g8kFqJxYwPaZcvAm3HsTr",
  "type": "click",
  "created_at": "2026-05-22T14:32:18.847Z",
  "data": {
    "link_id": "lnk_2g3jQpRyXz4Mn8VbF7Hkl",
    "short_url": "https://elido.me/abc123",
    "destination_url": "https://shop.example.com/spring",
    "click_id": "clk_2g8kFqJxYwPaZcvAm3HsTr",
    "ip_prefix": "203.0.113.0/24",
    "country": "DE",
    "city_geoname_id": 2950159,
    "user_agent_family": "Chrome 124",
    "device_type": "mobile",
    "os_family": "iOS 17.5",
    "referrer": "https://www.google.com",
    "utm_source": "newsletter",
    "utm_medium": "email",
    "utm_campaign": "spring-2026",
    "utm_term": null,
    "utm_content": null
  },
  "workspace_id": "ws_12"
}

Несколько деталей, которые стоит выделить:

  • ip_prefix, а не ip. Мы сохраняем префикс сети /24 (IPv4) или /48 (IPv6), а не полный адрес. В статье GDPR для сокращателей ссылок объясняется почему; на практике это означает, что подписчик получает достаточную географическую точность для аналитики без ответственности за обработку полных IP-адресов как персональных данных.
  • city_geoname_id, а не city_name. Идентификатор GeoNames стабилен для всех локалей; название города варьируется. Если вам нужно локализованное название, найдите идентификатор в дампе GeoNames.org один раз и кэшируйте результат.
  • user_agent_family, а не полная строка UA. Мы удаляем полный UA при приеме (это данные с высокой энтропией для снятия отпечатков); семейство — это браузер+мажорная версия, которые остаются.

Задержка между перенаправлением клиенту и срабатыванием вебхука обычно составляет от 200 мс до 2 с. События кликов сначала проходят через Redpanda, агрегируются для аналитики, а затем воркер fan-out отправляет вебхуки. Это тот же конвейер, который питает аналитику дашборда — в статье Fire-and-forget прием кликов описана механика очереди.

Формат события conversion#

События conversion срабатывают, когда клик сопоставляется с последующей конверсией — покупкой, регистрацией, лид-формой или чем-то еще, что вы подключили к конвейеру пересылки конверсий.

{
  "id": "evt_2g8mPzKlYxRcBnQvFt5HwS",
  "type": "conversion",
  "created_at": "2026-05-22T14:38:42.193Z",
  "data": {
    "click_id": "clk_2g8kFqJxYwPaZcvAm3HsTr",
    "link_id": "lnk_2g3jQpRyXz4Mn8VbF7Hkl",
    "conversion_id": "cnv_2g8mPzKlYxRcBnQvFt5HwS",
    "value": 49.50,
    "currency": "EUR",
    "event_name": "purchase",
    "product_id": "sku_42",
    "metadata": {
      "order_id": "ord_12345",
      "is_new_customer": true
    },
    "attribution_window_minutes": 6,
    "forwarded_to": ["meta_capi", "ga4_mp"]
  },
  "workspace_id": "ws_12"
}

click_id ссылается на исходное событие клика; вы можете объединить их на стороне сервера, чтобы восстановить путь «клик — конверсия». attribution_window_minutes — это время, прошедшее между кликом и конверсией, что полезно для моделирования атрибуции.

Массив forwarded_to сообщает, в какие пиксели платформ Elido уже отправил эту конверсию. Если ваш подписчик передает конверсии в ваше собственное хранилище данных, вы можете использовать это, чтобы избежать двойного учета в вашей аналитике.

Формат события link.created#

События жизненного цикла имеют более «тонкий» формат — только ресурс и субъект:

{
  "id": "evt_2g8mPzKlYxRcBnQvFt5HwS",
  "type": "link.created",
  "created_at": "2026-05-22T14:38:42.193Z",
  "data": {
    "link": {
      "id": "lnk_2g3jQpRyXz4Mn8VbF7Hkl",
      "slug": "abc123",
      "short_url": "https://elido.me/abc123",
      "destination_url": "https://shop.example.com/spring",
      "domain": "elido.me",
      "tags": ["spring-2026", "newsletter"],
      "created_at": "2026-05-22T14:38:42.193Z",
      "created_by": "usr_42"
    }
  },
  "workspace_id": "ws_12"
}

link.updated включает снимок previous наряду с новым состоянием; link.deleted включает конечное состояние ссылки на момент удаления. Полная схема находится в руководстве по эксплуатации /docs/guides/conversion-forwarding.

Проверка подписи#

Каждый запрос вебхука включает три HTTP-заголовка:

Elido-Signature: t=1716392538,v1=4f3b2e1d9c8a7b6f5e4d3c2b1a0f9e8d7c6b5a49382716054 ...
Elido-Webhook-Id: evt_2g8kFqJxYwPaZcvAm3HsTr
Elido-Delivery-Attempt: 1

Схема подписи следует модели Stripe: HMAC-SHA256 над {timestamp}.{body} с использованием секрета вебхука. Префикс v1= — это версия алгоритма подписи; новые версии алгоритма добавляются до того, как они станут стандартными, поэтому подписчики могут проверять несколько версий одновременно.

Проверка в Go:

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "strings"
    "time"
)

func verify(sigHeader, body, secret string) bool {
    parts := strings.Split(sigHeader, ",")
    var t int64
    var v1 string
    for _, p := range parts {
        kv := strings.SplitN(p, "=", 2)
        if len(kv) != 2 { continue }
        switch kv[0] {
        case "t":
            fmt.Sscanf(kv[1], "%d", &t)
        case "v1":
            v1 = kv[1]
        }
    }
    if time.Since(time.Unix(t, 0)) > 5*time.Minute {
        return false // reject stale requests
    }
    mac := hmac.New(sha256.New, []byte(secret))
    fmt.Fprintf(mac, "%d.%s", t, body)
    expected := hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(v1))
}

Проверка на «свежесть» в 5 минут — это то, о чем забывает большинство подписчиков. Без нее возможна атака повторного воспроизведения (replay attack) — злоумышленник, перехвативший валидный запрос, отправляет его позже, и он принимается, так как подпись все еще валидна. С проверкой по метке времени запрос принимается только в течение 5-минутного окна с момента отправки Elido.

Спецификация подписи задокументирована в шпаргалке OWASP по безопасности вебхуков; мы не изобретали этот паттерн, мы просто реализовали его.

Политика повторных попыток#

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

Вебхук срабатывает один раз при успешном сценарии: подписчик возвращает 2xx, диспетчер фиксирует успех, событие завершено. Более сложные случаи — это ответы не 2xx, сетевые ошибки и медленные ответы подписчиков.

График повторных попыток Elido:

ПопыткаЗадержка после предыдущейНакопленоСтатус
10начальная
2первая повторная
330с31с
45м 31с
51ч 5м 31с
67ч 5м 31с
724ч31ч 5м 31сфинальная

После 7-й попытки (~31 час после первой) диспетчер сдается и генерирует внутреннее событие webhook.failed. Конечная точка подписчика помечается как деградировавшая после трех последовательных сбоев в любых событиях; для деградировавших подписок бюджет повторных попыток сокращается на 24 часа. После 50 последовательных сбоев подписка приостанавливается, а владелец рабочего пространства получает уведомление.

Поведение повторных попыток учитывает заголовки Retry-After от подписчика. Если ваша конечная точка ограничивает Elido (возвращая 429 с Retry-After: 120), следующая попытка будет ждать 120 секунд вместо стандартных 30 секунд по графику.

Отсутствие ответа в течение 10 секунд рассматривается как тайм-аут и засчитывается как неудачная попытка. Бюджет в 10 секунд сделан намеренно щедрым — он покрывает задержку «холодного старта» для serverless-подписчиков, но если ваша конечная точка регулярно отвечает дольше 5 секунд, исправьте это в первую очередь; это обойдется вам в объеме повторных попыток.

Идемпотентность#

Подписчики могут получать одно и то же событие более одного раза.

Это не ошибка; это следствие того, как работает распределенная доставка сообщений. Если подписчик возвращает 504, потому что их бэкенд был медленным, но в итоге обработал событие, диспетчер повторит попытку; подписчик получит его дважды и может обработать дважды. То же самое событие может сработать дважды, если диспетчер упадет в процессе доставки и событие будет поставлено в очередь снова.

Способ решения: каждое событие имеет уникальный id (префикс evt_…). Подписчикам следует сохранять идентификаторы, которые они уже обработали (подойдет небольшая таблица ключ-значение; TTL 14 дней покрывает окно повторных попыток с запасом), и пропускать события, чей идентификатор они уже видели.

CREATE TABLE webhook_processed_events (
    event_id TEXT PRIMARY KEY,
    received_at TIMESTAMPTZ DEFAULT now()
);

-- in your handler:
INSERT INTO webhook_processed_events (event_id) VALUES ($1)
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id;
-- if the RETURNING is empty, you've already processed this event

ON CONFLICT DO NOTHING — это дешевая проверка на идемпотентность. Если вставка возвращает строку, это событие вы видите впервые; если ничего не возвращает — вы его уже обработали.

Для высоконагруженных подписчиков (>1 тыс. событий/сек) выделенный Redis SETNX с TTL работает аналогичным образом с меньшими затратами, чем строка в Postgres.

Порядок доставки#

Нет глобальной гарантии порядка. События с одним и тем же link_id отправляются в порядке подачи, но события от разных ссылок могут приходить вперемешку. Событие click во время T+0 и событие conversion во время T+10мс могут прийти к подписчику в любом порядке в зависимости от состояния пула воркеров.

Метки времени created_at являются авторитетными для порядка. Если вашему подписчику необходим строгий порядок, сортируйте по created_at на стороне сервера перед обработкой.

В частности, для пути клик → конверсия: событие конверсии всегда ссылается на click_id события клика, поэтому вы можете объединить их на стороне сервера, даже если они приходят не по порядку.

Вебхуки против опроса (polling) — компромисс#

В статье вебхуки против опроса для отслеживания кликов это подробно описано. Краткий ответ: вебхуки — правильный паттерн, когда (а) вам нужна низкая задержка при получении событий (<5 секунд), и (б) ваш подписчик доступен из публичного интернета по TLS. Опрос — правильный паттерн, когда (а) вам не нужно реальное время, (б) вы контролируете хранилище данных и просто хотите ежедневную/ежечасную пакетную загрузку, или (в) ваш подписчик находится в сети, которая не принимает входящий трафик.

Для большинства команд вебхуки — верное решение. Кривая повторных попыток изящно обрабатывает временные сбои; схема подписи обеспечивает безопасность; модель идемпотентности справляется с дублированием доставки. Работа лежит на стороне подписчика — создание надежного обработчика — и эта работа невелика по сравнению с созданием конвейера приема на основе опроса.

Операционные инструменты#

Страница вебхуков в дашборде показывает три вещи для каждой подписки:

  1. История доставки: каждое отправленное событие, HTTP-статус, возвращенный подписчиком, задержка и метка времени следующей попытки (если есть).
  2. Повтор (Replay): кнопка для повторной отправки каждого события. Полезно для тестирования изменений вашего обработчика.
  3. Тестовая конечная точка: кнопка для каждой подписки для отправки синтетического тестового события без имитации реального клика. Тестовое событие имеет type: "test" и фиксированную полезную нагрузку.

Конечные точки повтора и теста также доступны как API-эндпоинты (POST /v1/webhooks/{id}/events/{evt_id}/replay и POST /v1/webhooks/{id}/test).

Для отладки высокой пропускной способности руководство по наблюдаемости описывает, как подключить доставку вебхуков к вашим собственным метрикам — каждая отправка экспортируется как счетчик Prometheus и гистограмма.

Внешние ссылки#

Читайте также#

Попробуйте Elido

URL-сокращатель с хостингом в ЕС: собственные домены, глубокая аналитика, открытый API. Бесплатный тариф — без банковской карты.

Теги
вебхуки сокращателя ссылок
вебхук клика по ссылке
повтор вебхука
подпись вебхука
идемпотентность вебхука
доставка событий
полезная нагрузка вебхука

Читать дальше