Elido
9 min di letturaFunzionalità

Webhook per eventi di link: ogni forma, ogni tentativo

L'intera superficie dei webhook per gli eventi di URL shortener — le forme di payload per click, conversion, link.created e bio.click, oltre alla policy di retry, allo schema di firma e al modello di idempotenza.

Marius Voß
DevRel · edge infra
Diagramma hub-and-spoke con le sorgenti degli eventi di link a sinistra (click, conversion, link.created, bio.click) che confluiscono in un servizio centrale di dispatcher dei webhook, che distribuisce agli endpoint dei sottoscrittori con annotazioni di retry di 1s, 30s, 5m, 1h, 6h.

I webhook sono la parte della superficie API di un URL shortener che tutti implementano, ma che quasi nessuno implementa bene. Le parti difficili non sono la codifica — il payload è un oggetto JSON — ma i dettagli operativi: verifica della firma, policy di retry, idempotenza, garanzie di consegna e cosa succede quando l'endpoint del sottoscrittore è offline per due giorni.

Questo post documenta ogni evento webhook emesso da Elido, ogni forma di payload, la curva di retry e lo schema di firma. La guida rapida API + SDK per URL shortener copre la superficie API in entrata; questo è il lato in uscita.

I 12 tipi di evento#

Elido emette 12 tipi di evento webhook, raggruppati in tre famiglie:

Eventi di click e traffico: click, bio.click, qr.scan, conversion. Questi vengono attivati a ogni redirect o scansione dopo un breve ritardo in coda (descritto di seguito).

Eventi di ciclo di vita: link.created, link.updated, link.deleted, bio.published. Questi vengono attivati dal livello API quando il record sottostante viene modificato.

Eventi di aggregazione e ops: daily.summary, campaign.ended, alert.threshold_exceeded, quota.warning. Questi vengono attivati su pianificazione o al superamento di una soglia.

Un sottoscrittore registra un webhook tramite POST /v1/webhooks con un URL di destinazione e un array di tipi di evento che desidera ricevere. La richiesta di sottoscrizione completa:

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
}

Il secret è la chiave HMAC utilizzata per firmare le richieste in uscita. È opaco per Elido; non lo registriamo né lo visualizziamo mai dopo la risposta alla chiamata di creazione.

Il payload dell'evento click#

Per volume, questo è l'evento più rilevante. Ogni redirect attraverso un qualsiasi short link produce un evento click dopo che il redirect è stato servito al client. La forma:

{
  "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"
}

Alcuni dettagli degni di nota:

  • ip_prefix, non ip. Conserviamo solo il prefisso di rete /24 (IPv4) o /48 (IPv6), non l'indirizzo completo. Il post sul GDPR per gli URL shortener spiega perché; l'effetto pratico è che il tuo sottoscrittore ottiene una precisione geografica sufficiente per l'analisi senza la responsabilità legale legata ai dati personali degli IP completi.
  • city_geoname_id, non city_name. L'ID GeoNames è stabile tra le varie lingue; il nome della città varia. Se hai bisogno di un nome localizzato, consulta l'ID tramite il dump di GeoNames.org una volta e memorizza il risultato.
  • user_agent_family, non l'intera stringa UA. Rimuoviamo l'UA completo durante l'ingestion (è un dato di fingerprinting ad alta entropia); la famiglia è la combinazione browser+versione-maggiore che rimane.

Il ritardo tra il redirect che serve il client e l'attivazione del webhook è solitamente compreso tra 200ms e 2s. Gli eventi di click passano prima attraverso Redpanda, vengono aggregati per l'analisi e poi un worker di fan-out emette i webhook. Questo è lo stesso processo che alimenta l'analisi della dashboard — il post sull'ingestion click fire-and-forget copre le meccaniche della coda.

Il payload dell'evento conversion#

Gli eventi di conversion si attivano quando un click viene associato a una conversione a valle — un acquisto, un'iscrizione, un modulo lead o qualsiasi altra cosa collegata al processo di inoltro delle conversioni.

{
  "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"
}

Il click_id si collega all'evento di click originale; puoi unire i due lato server per ricostruire il percorso dal click alla conversione. attribution_window_minutes è il tempo trascorso tra il click e l'attivazione della conversione, utile per la modellazione dell'attribuzione.

L'array forwarded_to indica a quali pixel di piattaforma Elido ha già inviato questa conversione. Se il tuo sottoscrittore sta collegando le conversioni al proprio data warehouse, puoi usarlo per evitare il conteggio doppio nelle analisi a valle.

Il payload dell'evento link.created#

Gli eventi di ciclo di vita hanno una forma più sottile — solo la risorsa e l'attore:

{
  "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 include un'istantanea previous accanto al nuovo stato; link.deleted include lo stato finale del link al momento della cancellazione. Lo schema completo si trova nella guida operativa /docs/guides/conversion-forwarding.

Verifica della firma#

Ogni richiesta webhook include tre intestazioni HTTP:

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

Lo schema di firma segue il modello di Stripe: HMAC-SHA256 su {timestamp}.{body} usando il secret del webhook. Il prefisso v1= è la versione dell'algoritmo di firma; le nuove versioni dell'algoritmo vengono aggiunte prima di diventare quelle predefinite, così i sottoscrittori possono verificare più versioni contemporaneamente.

Verifica in 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 // rifiuta richieste obsolete
    }
    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))
}

Il controllo di obsolescenza a 5 minuti è quello che la maggior parte dei sottoscrittori dimentica. Senza di esso, un attacco di replay — un malintenzionato che ha catturato una richiesta valida e la riproduce in seguito — ha successo perché la firma è ancora valida. Con il controllo del timestamp, la richiesta viene accettata solo entro una finestra di 5 minuti dal momento in cui Elido l'ha emessa.

La specifica della firma è documentata nel foglio informativo OWASP sulla sicurezza dei webhook; non abbiamo inventato noi il pattern, lo abbiamo solo implementato.

Policy di retry#

Questa è la parte in cui la maggior parte delle implementazioni webhook risulta trascurata.

Un webhook si attiva una volta nel percorso positivo: il sottoscrittore restituisce 2xx, il dispatcher registra il successo, l'evento è completato. I casi più complessi sono risposte non-2xx, errori di rete e sottoscrittori che rispondono lentamente.

Il programma di retry di Elido:

TentativoRitardo dopo il precedenteCumulativoStato
10iniziale
21s1sprimo retry
330s31s
45m5m 31s
51h1h 5m 31s
66h7h 5m 31s
724h31h 5m 31sfinale

Dopo il settimo tentativo (~31 ore dopo il primo tentativo), il dispatcher rinuncia ed emette un evento interno webhook.failed. L'endpoint del sottoscrittore viene contrassegnato come degradato dopo tre fallimenti consecutivi su qualsiasi evento; le sottoscrizioni degradate ricevono un budget di retry ridotto per 24 ore. Dopo 50 fallimenti consecutivi, la sottoscrizione viene sospesa e il proprietario dell'area di lavoro viene avvisato.

Il comportamento di retry rispetta le intestazioni Retry-After del sottoscrittore. Se il tuo endpoint sta limitando la frequenza di Elido (restituendo 429 con Retry-After: 120), il tentativo successivo attenderà 120 secondi invece dei 30s predefiniti dal programma.

La mancata risposta entro 10 secondi viene trattata come un timeout e conta come tentativo fallito. Il budget di 10 secondi è generoso apposta — copre la latenza di cold-start sui sottoscrittori serverless — ma se il tuo endpoint impiega regolarmente più di 5 secondi, risolvi prima quello; ti costerà in termini di volume di retry.

Idempotenza#

I sottoscrittori possono ricevere lo stesso evento più di una volta.

Questo non è un bug; è la conseguenza di come funziona la distribuzione dei messaggi in sistemi distribuiti. Se un sottoscrittore restituisce un 504 perché il proprio backend era lento ma alla fine ha elaborato l'evento, il dispatcher riproverà; il sottoscrittore lo riceverà due volte e potrebbe elaborarlo due volte. Lo stesso evento può anche attivarsi due volte se il dispatcher si blocca a metà consegna e l'evento viene rimesso in coda.

La mitigazione: ogni evento ha un id unico (il prefisso evt_…). I sottoscrittori dovrebbero memorizzare gli ID già elaborati (una piccola tabella chiave-valore funziona; un TTL di 14 giorni copre la finestra di retry con margine) e ignorare gli eventi con un ID già visto.

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

-- nel tuo handler:
INSERT INTO webhook_processed_events (event_id) VALUES ($1)
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id;
-- se il RETURNING è vuoto, hai già elaborato questo evento

L'ON CONFLICT DO NOTHING è il controllo di idempotenza economico. Se l'INSERT restituisce una riga, questa è la prima volta che vedi l'evento; se non restituisce nulla, lo hai già elaborato.

Per sottoscrittori ad alto throughput (>1k eventi/sec), un SETNX di Redis dedicato con TTL funziona allo stesso modo a un costo inferiore rispetto a una riga Postgres.

Ordinamento della consegna#

Non esiste una garanzia di ordinamento globale. Gli eventi dallo stesso link_id vengono inviati nell'ordine di sottomissione, ma gli eventi da link diversi possono arrivare in ordine intercalato. Un evento click al tempo T+0 e un evento conversion al tempo T+10ms potrebbero arrivare al tuo sottoscrittore in qualsiasi ordine a seconda dello stato del pool di worker.

I timestamp created_at fanno fede per l'ordinamento. Se il tuo sottoscrittore necessita di un ordinamento rigoroso, ordina per created_at lato server prima dell'elaborazione.

Specificamente per il percorso click → conversion: l'evento di conversione fa sempre riferimento al click_id dell'evento di click, quindi puoi unirli lato server anche se arrivano fuori ordine.

Webhook vs polling — il compromesso#

Il post sui webhook vs polling per il click tracking copre questo argomento in dettaglio. La risposta breve: i webhook sono il pattern corretto quando (a) hai bisogno di bassa latenza all'arrivo dell'evento (<5 secondi) e (b) il tuo sottoscrittore è raggiungibile dalla rete pubblica con TLS. Il polling è il pattern corretto quando (a) non hai bisogno di tempo reale, (b) controlli il data warehouse e vuoi solo un batch giornaliero/orario o (c) il tuo sottoscrittore si trova in una rete che non accetta traffico in entrata.

Per la maggior parte dei team, i webhook sono la risposta. La curva di retry gestisce i fallimenti transitori con grazia; lo schema di firma gestisce la sicurezza; il modello di idempotenza gestisce la duplicazione della consegna. Il lavoro è lato sottoscrittore — costruire un gestore robusto — ed è un lavoro minimo rispetto alla costruzione di una pipeline di ingestion basata su polling.

Strumenti operativi#

La pagina dei webhook della dashboard mostra tre elementi per ogni sottoscrizione:

  1. Cronologia di consegna: ogni evento inviato, lo stato HTTP restituito dal sottoscrittore, la latenza e il prossimo timestamp di retry (se presente).
  2. Replay: un pulsante per ogni evento per riprodurlo. Utile per testare le modifiche al tuo gestore.
  3. Test endpoint: un pulsante per ogni sottoscrizione per inviare un evento di test sintetico senza attivare un click reale. L'evento di test ha type: "test" e un payload fisso.

I replay e gli endpoint di test sono esposti anche come endpoint API (POST /v1/webhooks/{id}/events/{evt_id}/replay e POST /v1/webhooks/{id}/test).

Per il debug ad alto throughput, la guida all'osservabilità copre come collegare la consegna dei webhook alle proprie metriche — ogni invio viene esportato come contatore Prometheus e istogramma.

Riferimenti esterni#

Letture correlate#

Prova Elido

Accorciatore di URL ospitato nell'UE: domini personalizzati, analisi approfondite e API aperta. Piano gratuito — senza carta di credito.

Tag
webhook per url shortener
webhook per click link
retry webhook
firma webhook
idempotenza webhook
consegna eventi
payload webhook

Continua a leggere