Вебхуки — это та часть 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:
| Попытка | Задержка после предыдущей | Накоплено | Статус |
|---|---|---|---|
| 1 | — | 0 | начальная |
| 2 | 1с | 1с | первая повторная |
| 3 | 30с | 31с | |
| 4 | 5м | 5м 31с | |
| 5 | 1ч | 1ч 5м 31с | |
| 6 | 6ч | 7ч 5м 31с | |
| 7 | 24ч | 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. Опрос — правильный паттерн, когда (а) вам не нужно реальное время, (б) вы контролируете хранилище данных и просто хотите ежедневную/ежечасную пакетную загрузку, или (в) ваш подписчик находится в сети, которая не принимает входящий трафик.
Для большинства команд вебхуки — верное решение. Кривая повторных попыток изящно обрабатывает временные сбои; схема подписи обеспечивает безопасность; модель идемпотентности справляется с дублированием доставки. Работа лежит на стороне подписчика — создание надежного обработчика — и эта работа невелика по сравнению с созданием конвейера приема на основе опроса.
Операционные инструменты#
Страница вебхуков в дашборде показывает три вещи для каждой подписки:
- История доставки: каждое отправленное событие, HTTP-статус, возвращенный подписчиком, задержка и метка времени следующей попытки (если есть).
- Повтор (Replay): кнопка для повторной отправки каждого события. Полезно для тестирования изменений вашего обработчика.
- Тестовая конечная точка: кнопка для каждой подписки для отправки синтетического тестового события без имитации реального клика. Тестовое событие имеет
type: "test"и фиксированную полезную нагрузку.
Конечные точки повтора и теста также доступны как API-эндпоинты (POST /v1/webhooks/{id}/events/{evt_id}/replay и POST /v1/webhooks/{id}/test).
Для отладки высокой пропускной способности руководство по наблюдаемости описывает, как подключить доставку вебхуков к вашим собственным метрикам — каждая отправка экспортируется как счетчик Prometheus и гистограмма.
Внешние ссылки#
- Шпаргалка OWASP по безопасности вебхуков — обоснование схемы подписи.
- Документация Stripe по вебхукам — эталонная реализация вебхуков с подписью HMAC.
- RFC 7234 — Кэширование в HTTP/1.1 — семантика
Retry-After.
Читайте также#
- Смарт-ссылки: объяснение — краеугольный камень кластера функций.
- Вебхуки против опроса для отслеживания кликов — когда что выбрать.
- Быстрый старт по API + SDK сокращателя ссылок — входящая часть API.
- Fire-and-forget прием кликов с Redpanda — очередь за диспетчером.
- Серверная отслеживаемость конверсий — то, что вызывает событие
conversion. - Поверхности продукта:
/features/webhooks,/solutions/developers. - Руководства по эксплуатации:
/docs/guides/conversion-forwarding,/docs/guides/observability.