Webhooki to ta część powierzchni API skracacza adresów URL, którą wszyscy udostępniają, a prawie nikt nie robi tego dobrze. Najtrudniejsze nie jest kodowanie — ładunek (payload) to obiekt JSON — ale szczegóły operacyjne: weryfikacja podpisu, polityka ponownych prób, idempotentność, gwarancje dostarczenia oraz to, co dzieje się, gdy punkt końcowy subskrybenta nie działa przez dwa dni.
Ten post dokumentuje każde zdarzenie webhooka emitowane przez Elido, każdy kształt ładunku (payload), krzywą ponownych prób oraz schemat podpisywania. URL shortener API + SDKs quickstart obejmuje przychodzącą powierzchnię API; to jest strona wychodząca.
12 typów zdarzeń#
Elido emituje 12 typów zdarzeń webhooka, pogrupowanych w trzy rodziny:
Zdarzenia kliknięć i ruchu: click, bio.click, qr.scan, conversion. Wyzwalane przy każdym przekierowaniu lub skanowaniu po niewielkim opóźnieniu kolejki (opisanym poniżej).
Zdarzenia cyklu życia: link.created, link.updated, link.deleted, bio.published. Wyzwalane z warstwy API, gdy podstawowy rekord ulega zmianie.
Zdarzenia agregacji i operacyjne: daily.summary, campaign.ended, alert.threshold_exceeded, quota.warning. Wyzwalane zgodnie z harmonogramem lub po przekroczeniu progu.
Subskrybent rejestruje webhook pod adresem POST /v1/webhooks z docelowym adresem URL i tablicą typów zdarzeń, które chce otrzymywać. Pełne żądanie subskrypcji:
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 to klucz HMAC używany do podpisywania wychodzących żądań. Jest on nieprzezroczysty dla Elido; nigdy nie logujemy ani nie wyświetlamy go po udzieleniu odpowiedzi na żądanie utworzenia.
Ładunek (payload) zdarzenia kliknięcia#
Pod względem wolumenu jest to zdarzenie, na którym najbardziej Ci zależy. Każde przekierowanie przez dowolny krótki link generuje jedno zdarzenie click po obsłużeniu przekierowania do klienta. Kształt:
{
"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"
}
Kilka szczegółów wartych podkreślenia:
ip_prefix, a nieip. Zachowujemy prefiks sieci /24 (IPv4) lub /48 (IPv6), a nie pełny adres. Post GDPR for URL shorteners wyjaśnia dlaczego; praktycznym skutkiem jest to, że subskrybent otrzymuje wystarczającą precyzję geograficzną do analizy bez odpowiedzialności za dane osobowe wynikającej z pełnych adresów IP.city_geoname_id, a niecity_name. Identyfikator GeoNames jest stabilny w różnych lokalizacjach; nazwa miasta ulega zmianie. Jeśli potrzebujesz zlokalizowanej nazwy, sprawdź identyfikator w zrzucie danych GeoNames.org jeden raz i zcache'uj wynik.user_agent_family, a nie pełny ciąg UA. Usuwamy pełny UA podczas przyjmowania danych (są to dane wysokiej entropii służące do fingerprintingu); rodzina to przeglądarka + wersja główna, która pozostaje.
Opóźnienie między obsłużeniem przekierowania przez klienta a uruchomieniem webhooka wynosi zazwyczaj od 200 ms do 2 s. Zdarzenia kliknięć przepływają najpierw przez Redpanda, są agregowane na potrzeby analityki, a następnie procesor fan-out emituje webhooki. To ta sama potok, który zasila analitykę dashboardu — post fire-and-forget click ingestion opisuje mechanikę kolejki.
Ładunek (payload) zdarzenia konwersji#
Zdarzenia konwersji są wyzwalane, gdy kliknięcie zostanie dopasowane do konwersji w systemie docelowym — zakupu, rejestracji, formularza kontaktowego lub czegokolwiek innego, co podłączysz do potoku przekazywania konwersji.
{
"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 łączy zdarzenie z oryginalnym kliknięciem; możesz połączyć oba po stronie serwera, aby zrekonstruować ścieżkę od kliknięcia do konwersji. attribution_window_minutes to czas, jaki upłynął między kliknięciem a wyzwoleniem konwersji, co jest przydatne w modelowaniu atrybucji.
Tablica forwarded_to informuje, do których pikseli platform Elido już przekazało tę konwersję. Jeśli Twój subskrybent przesyła konwersje do własnego hurtowni danych, możesz użyć tego, aby uniknąć podwójnego liczenia w analityce zewnętrznej.
Ładunek (payload) zdarzenia link.created#
Zdarzenia cyklu życia mają węższy kształt — tylko zasób i aktor:
{
"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 zawiera migawkę previous obok nowego stanu; link.deleted zawiera końcowy stan linku w momencie usunięcia. Pełny schemat znajduje się w przewodniku operacyjnym /docs/guides/conversion-forwarding.
Weryfikacja podpisu#
Każde żądanie webhooka zawiera trzy nagłówki HTTP:
Elido-Signature: t=1716392538,v1=4f3b2e1d9c8a7b6f5e4d3c2b1a0f9e8d7c6b5a49382716054 ...
Elido-Webhook-Id: evt_2g8kFqJxYwPaZcvAm3HsTr
Elido-Delivery-Attempt: 1
Schemat podpisywania jest zgodny z modelem Stripe: HMAC-SHA256 nad {timestamp}.{body} przy użyciu klucza webhooka. Przedrostek v1= to wersja algorytmu podpisywania; nowe wersje algorytmu są dodawane, zanim staną się domyślnymi, dzięki czemu subskrybenci mogą weryfikować wiele wersji jednocześnie.
Weryfikacja w 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 // odrzuć przestarzałe żądania
}
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))
}
Pięciominutowa weryfikacja świeżości to część, o której zapomina większość subskrybentów. Bez niej atak typu replay — napastnik, który przechwycił prawidłowe żądanie i odtworzył je później — zakończy się sukcesem, ponieważ podpis jest nadal ważny. Dzięki weryfikacji znacznika czasu, żądanie jest akceptowane tylko w ciągu 5 minut od momentu wysłania go przez Elido.
Specyfikacja podpisu jest udokumentowana w OWASP cheat sheet on webhook security; nie wymyśliliśmy tego wzorca, po prostu go wdrożyliśmy.
Polityka ponownych prób#
To jest element, w którym większość implementacji webhooków staje się niedbała.
Webhook wyzwalany jest raz w przypadku pomyślnego działania: subskrybent zwraca 2xx, dyspozytor rejestruje sukces, zdarzenie jest zakończone. Trudniejsze przypadki to odpowiedzi inne niż 2xx, błędy sieciowe i subskrybenci, którzy odpowiadają wolno.
Harmonogram ponownych prób Elido:
| Próba | Opóźnienie po poprzedniej | Skumulowane | Status |
|---|---|---|---|
| 1 | — | 0 | początkowa |
| 2 | 1s | 1s | pierwsza ponowna próba |
| 3 | 30s | 31s | |
| 4 | 5m | 5m 31s | |
| 5 | 1h | 1h 5m 31s | |
| 6 | 6h | 7h 5m 31s | |
| 7 | 24h | 31h 5m 31s | ostateczna |
Po 7. próbie (około 31 godzin po pierwszej próbie) dyspozytor poddaje się i emituje wewnętrzne zdarzenie webhook.failed. Punkt końcowy subskrybenta jest oznaczany jako zdegradowany po trzech kolejnych niepowodzeniach w dowolnych zdarzeniach; zdegradowane subskrypcje otrzymują zmniejszony budżet ponownych prób na 24 godziny. Po 50 kolejnych niepowodzeniach subskrypcja jest wstrzymywana, a właściciel obszaru roboczego jest powiadamiany.
Zachowanie przy ponownych próbach uwzględnia nagłówki Retry-After od subskrybenta. Jeśli Twój punkt końcowy ogranicza szybkość Elido (zwracając 429 z Retry-After: 120), kolejna próba poczeka 120 sekund zamiast domyślnych 30s.
Brak odpowiedzi w ciągu 10 sekund jest traktowany jako timeout i liczony jako nieudana próba. Budżet 10 sekund jest celowo hojny — pokrywa opóźnienia typu cold-start u subskrybentów działających w modelu serverless — ale jeśli Twój punkt końcowy regularnie potrzebuje więcej niż 5 sekund, napraw to w pierwszej kolejności; będzie Cię to kosztować w wolumenie ponownych prób.
Idempotentność#
Subskrybenci mogą otrzymać to samo zdarzenie więcej niż raz.
To nie jest błąd; to konsekwencja sposobu działania rozproszonego dostarczania wiadomości. Jeśli subskrybent zwróci 504, ponieważ jego backend był wolny, ale ostatecznie przetworzył zdarzenie, dyspozytor ponowi próbę; subskrybent otrzyma je dwukrotnie i może przetworzyć je dwukrotnie. To samo zdarzenie może również wystąpić dwukrotnie, jeśli dyspozytor ulegnie awarii w trakcie dostarczania i zdarzenie zostanie ponownie dodane do kolejki.
Łagodzenie: każde zdarzenie ma unikalny id (z przedrostkiem evt_…). Subskrybenci powinni przechowywać identyfikatory, które już przetworzyli (mała tabela klucz-wartość działa dobrze; TTL wynoszący 14 dni pokrywa okno ponownych prób z zapasem) i pomijać zdarzenia, których identyfikatory już widzieli.
CREATE TABLE webhook_processed_events (
event_id TEXT PRIMARY KEY,
received_at TIMESTAMPTZ DEFAULT now()
);
-- w twoim handlerze:
INSERT INTO webhook_processed_events (event_id) VALUES ($1)
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id;
-- jeśli RETURNING jest puste, oznacza to, że już przetworzyłeś to zdarzenie
ON CONFLICT DO NOTHING to tani sposób na sprawdzenie idempotentności. Jeśli INSERT zwraca wiersz, jest to pierwszy raz, gdy widzisz to zdarzenie; jeśli nic nie zwraca, przetworzyłeś je już wcześniej.
Dla subskrybentów o wysokiej przepustowości (>1 tys. zdarzeń/sek) dedykowany Redis SETNX z TTL działa w ten sam sposób przy niższym koszcie niż wiersz Postgres.
Kolejność dostarczania#
Nie ma globalnej gwarancji kolejności. Zdarzenia z tego samego link_id są wysyłane w kolejności zgłoszenia, ale zdarzenia z różnych linków mogą docierać w dowolnej kolejności. Zdarzenie click w czasie T+0 i zdarzenie conversion w czasie T+10ms mogą dotrzeć do Twojego subskrybenta w dowolnej kolejności, w zależności od stanu puli procesorów.
Znaczniki czasu created_at są wiążące dla określenia kolejności. Jeśli Twój subskrybent wymaga ścisłej kolejności, posortuj je według created_at po stronie serwera przed przetworzeniem.
Szczególnie w przypadku ścieżki kliknięcie → konwersja: zdarzenie konwersji zawsze odwołuje się do click_id zdarzenia kliknięcia, dzięki czemu możesz je połączyć po stronie serwera, nawet jeśli dotrą w niewłaściwej kolejności.
Webhooki kontra polling — kompromis#
Post webhooks vs polling for click tracking omawia to szczegółowo. Krótka odpowiedź: webhooki są właściwym wzorcem, gdy (a) potrzebujesz niskiego opóźnienia w dotarciu zdarzenia (<5 sekund) i (b) Twój subskrybent jest osiągalny z publicznego Internetu przez TLS. Polling jest właściwym wzorcem, gdy (a) nie potrzebujesz czasu rzeczywistego, (b) kontrolujesz hurtownię danych i chcesz po prostu codzienną/godzinną partię danych lub (c) Twój subskrybent znajduje się w sieci, która nie akceptuje przychodzącego ruchu.
Dla większości zespołów odpowiedzią są webhooki. Krzywa ponownych prób obsługuje przejściowe awarie płynnie; schemat podpisywania dba o bezpieczeństwo; model idempotentności radzi sobie z powielaniem dostarczenia. Praca leży po stronie subskrybenta — budowa solidnego handlera — a ta praca jest niewielka w porównaniu do budowy potoku inżestii opartego na pollingu.
Narzędzia operacyjne#
Strona webhooków w dashboardzie pokazuje trzy rzeczy dla każdej subskrypcji:
- Historia dostarczania: każde wysłane zdarzenie, kod statusu HTTP zwrócony przez subskrybenta, opóźnienie i znacznik czasu następnej ponownej próby (jeśli istnieje).
- Replay: przycisk dla każdego zdarzenia, aby uruchomić je ponownie. Przydatne do testowania zmian w Twoim handlerze.
- Testowy punkt końcowy: przycisk dla każdej subskrypcji, aby wysłać syntetyczne zdarzenie testowe bez wyzwalania rzeczywistego kliknięcia. Zdarzenie testowe ma
type: "test"i ustalony ładunek (payload).
Replay i testowe punkty końcowe są również udostępnione jako punkty końcowe API (POST /v1/webhooks/{id}/events/{evt_id}/replay oraz POST /v1/webhooks/{id}/test).
Do debugowania przy wysokiej przepustowości, przewodnik po obserwowalności opisuje, jak przekazywać dostarczanie webhooków do własnych metryk — każde wysłanie jest eksportowane jako licznik Prometheus i histogram.
Zewnętrzne odniesienia#
- OWASP webhook security cheat sheet — uzasadnienie schematu podpisywania.
- Stripe's webhook documentation — referencyjna implementacja webhooków podpisanych HMAC.
- RFC 7234 — HTTP/1.1 Caching — opisuje semantykę
Retry-After.
Powiązane lektury#
- Smart links explained — fundament klastra funkcji.
- Webhooks vs polling for click tracking — kiedy wybrać jedno, a kiedy drugie.
- URL shortener API + SDKs quickstart — przychodząca powierzchnia API.
- Fire-and-forget click ingestion with Redpanda — kolejka za dyspozytorem.
- Server-side conversion tracking — co wyzwala zdarzenie
conversion. - Powierzchnie produktu:
/features/webhooks,/solutions/developers. - Przewodnik operacyjny:
/docs/guides/conversion-forwarding,/docs/guides/observability.