Webhooks são a parte da superfície de API de um encurtador de URL que todos implementam, mas que quase ninguém faz bem. As partes difíceis não são a codificação — o payload é um objeto JSON — mas os detalhes operacionais: verificação de assinatura, política de novas tentativas, idempotência, garantias de entrega e o que acontece quando o endpoint do assinante fica fora do ar por dois dias.
Este post documenta cada evento de webhook que o Elido emite, cada formato de payload, a curva de novas tentativas e o esquema de assinatura. O URL shortener API + SDKs quickstart cobre a superfície de API de entrada; este é o lado de saída.
Os 12 tipos de eventos#
O Elido emite 12 tipos de eventos de webhook, agrupados em três famílias:
Eventos de clique e tráfego: click, bio.click, qr.scan, conversion. Estes são disparados a cada redirecionamento ou escaneamento após um pequeno atraso na fila (descrito abaixo).
Eventos de ciclo de vida: link.created, link.updated, link.deleted, bio.published. Estes são disparados da camada de API quando o registro subjacente é alterado.
Eventos de agregação e operações: daily.summary, campaign.ended, alert.threshold_exceeded, quota.warning. Estes são disparados de acordo com um cronograma ou quando um limite é excedido.
Um assinante registra um webhook em POST /v1/webhooks com uma URL de destino e uma matriz de tipos de eventos que deseja receber. A solicitação de assinatura 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
}
O secret é a chave HMAC usada para assinar as solicitações de saída. Ele é opaco para o Elido; nunca registramos ou exibimos após a resposta da chamada de criação.
O payload do evento de clique#
Por volume, este é o evento que mais lhe interessa. Cada redirecionamento através de qualquer link curto produz um evento click após o redirecionamento ter sido servido ao cliente. O formato:
{
"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"
}
Alguns detalhes que vale a pena destacar:
ip_prefix, nãoip. Mantemos o prefixo de rede /24 (IPv4) ou /48 (IPv6), não o endereço completo. O post sobre GDPR para encurtadores de URL explica o porquê; o efeito prático é que seu assinante obtém precisão geográfica suficiente para análise sem a responsabilidade de dados pessoais de IPs completos.city_geoname_id, nãocity_name. O ID do GeoNames é estável entre localidades; o nome da cidade varia. Se você precisar de um nome localizado, consulte o ID no dump do GeoNames.org uma vez e armazene o resultado em cache.user_agent_family, não a string UA completa. Removemos o UA completo durante a ingestão (são dados de impressão digital de alta entropia); a família é o navegador+versão principal que sobrevive.
O atraso entre o redirecionamento que atende o cliente e o disparo do webhook é normalmente de 200ms a 2s. Os eventos de clique fluem primeiro pelo Redpanda, são agregados para análise e, em seguida, um trabalhador de distribuição emite os webhooks. Este é o mesmo pipeline que alimenta as análises do painel — o post sobre ingestão de cliques fire-and-forget cobre a mecânica da fila.
O payload do evento de conversão#
Eventos de conversão são disparados quando um clique é correspondido a uma conversão downstream — uma compra, inscrição, formulário de lead ou qualquer outra coisa que você conectar ao pipeline de encaminhamento de conversão.
{
"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"
}
O click_id vincula de volta ao evento de clique original; você pode unir os dois no lado do servidor para reconstruir o caminho do clique à conversão. O attribution_window_minutes é o tempo decorrido entre o clique e o disparo da conversão, o que é útil para modelagem de atribuição.
A matriz forwarded_to informa a quais pixels de plataforma o Elido já enviou essa conversão. Se o seu assinante estiver encaminhando conversões para o seu próprio data warehouse, você pode usar isso para evitar contagem dupla em suas análises downstream.
O payload do evento link.created#
Eventos de ciclo de vida têm um formato mais fino — apenas o recurso e o ator:
{
"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 inclui um snapshot previous ao lado do novo estado; link.deleted inclui o estado final do link no momento da exclusão. O esquema completo encontra-se no guia operacional /docs/guides/conversion-forwarding.
Verificação de assinatura#
Cada solicitação de webhook inclui três cabeçalhos HTTP:
Elido-Signature: t=1716392538,v1=4f3b2e1d9c8a7b6f5e4d3c2b1a0f9e8d7c6b5a49382716054 ...
Elido-Webhook-Id: evt_2g8kFqJxYwPaZcvAm3HsTr
Elido-Delivery-Attempt: 1
O esquema de assinatura segue o modelo da Stripe: HMAC-SHA256 sobre {timestamp}.{body} usando o segredo do webhook. O prefixo v1= é a versão do algoritmo de assinatura; novas versões do algoritmo são adicionadas antes de serem definidas como padrão, para que os assinantes possam verificar várias versões ao mesmo tempo.
Verificando em 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 // rejeitar solicitações obsoletas
}
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))
}
A verificação de obsolescência de 5 minutos é a parte que a maioria dos assinantes esquece. Sem ela, um ataque de replay — um invasor que capturou uma solicitação válida e a reproduz posteriormente — teria sucesso porque a assinatura ainda é válida. Com a verificação de timestamp, a solicitação só é aceita dentro de uma janela de 5 minutos a partir do momento em que o Elido a emitiu.
A especificação da assinatura está documentada na cheat sheet da OWASP sobre segurança de webhook; não inventamos o padrão, apenas o implementamos.
Política de novas tentativas#
Esta é a parte onde a maioria das implementações de webhook se torna desleixada.
Um webhook dispara uma vez no caminho feliz: o assinante retorna 2xx, o despachante registra o sucesso, o evento está concluído. Os casos mais difíceis são respostas que não sejam 2xx, erros de rede e assinantes que respondem lentamente.
O cronograma de novas tentativas do Elido:
| Tentativa | Atraso após o anterior | Cumulativo | Status |
|---|---|---|---|
| 1 | — | 0 | inicial |
| 2 | 1s | 1s | primeira tentativa |
| 3 | 30s | 31s | |
| 4 | 5m | 5m 31s | |
| 5 | 1h | 1h 5m 31s | |
| 6 | 6h | 7h 5m 31s | |
| 7 | 24h | 31h 5m 31s | final |
Após a 7ª tentativa (~31 horas após a primeira tentativa), o despachante desiste e emite um evento interno webhook.failed. O endpoint do assinante é marcado como degradado após três falhas consecutivas em quaisquer eventos; assinaturas degradadas recebem um orçamento reduzido de novas tentativas por 24 horas. Após 50 falhas consecutivas, a assinatura é pausada e o proprietário do espaço de trabalho é notificado.
O comportamento de novas tentativas respeita os cabeçalhos Retry-After do assinante. Se o seu endpoint estiver limitando a taxa do Elido (retornando 429 com Retry-After: 120), a próxima tentativa aguarda 120 segundos em vez dos 30s padrão do cronograma.
A falha em responder dentro de 10 segundos é tratada como um tempo limite (timeout) e conta como uma tentativa falha. O orçamento de 10 segundos é generoso de propósito — ele cobre a latência de inicialização fria (cold-start) em assinantes serverless — mas se o seu endpoint regularmente leva mais de 5 segundos, corrija isso primeiro; isso custará caro em volume de novas tentativas.
Idempotência#
Assinantes podem receber o mesmo evento mais de uma vez.
Isso não é um bug; é a consequência de como a entrega de mensagens distribuídas funciona. Se um assinante retorna um 504 porque seu backend estava lento, mas eventualmente processou o evento, o despachante fará uma nova tentativa; o assinante o receberá duas vezes e pode processá-lo duas vezes. O mesmo evento também pode disparar duas vezes se o despachante travar no meio da entrega e o evento for recolocado na fila.
A mitigação: cada evento tem um id único (o prefixo evt_…). Os assinantes devem armazenar os IDs que já processaram (uma pequena tabela chave-valor funciona; um TTL de 14 dias cobre a janela de novas tentativas com margem) e ignorar eventos cujo ID já viram antes.
CREATE TABLE webhook_processed_events (
event_id TEXT PRIMARY KEY,
received_at TIMESTAMPTZ DEFAULT now()
);
-- no seu manipulador:
INSERT INTO webhook_processed_events (event_id) VALUES ($1)
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id;
-- se o RETURNING estiver vazio, você já processou este evento
O ON CONFLICT DO NOTHING é a verificação de idempotência barata. Se o insert retornar uma linha, esta é a primeira vez que você vê o evento; se não retornar nada, você já o processou.
Para assinantes de alto throughput (>1k eventos/seg), um Redis SETNX dedicado com TTL funciona da mesma maneira com custo menor do que uma linha Postgres.
Ordem de entrega#
Não há garantia de ordenação global. Eventos do mesmo link_id são despachados na ordem de envio, mas eventos de links diferentes podem chegar intercalados. Um evento click no tempo T+0 e um evento conversion no tempo T+10ms podem chegar ao seu assinante em qualquer ordem, dependendo do estado do pool de trabalhadores.
Os timestamps created_at são a autoridade para ordenação. Se o seu assinante precisa de ordenação estrita, ordene por created_at no lado do servidor antes de processar.
Para o caminho clique → conversão especificamente: o evento de conversão sempre referencia o click_id do evento de clique, então você pode uni-los no lado do servidor mesmo se chegarem fora de ordem.
Webhooks vs polling — o trade-off#
O post sobre webhooks vs polling para rastreamento de cliques cobre isso em detalhes. A resposta curta: webhooks são o padrão certo quando (a) você precisa de baixa latência na chegada do evento (<5 segundos), e (b) seu assinante pode ser alcançado pela internet pública com TLS. Polling é o padrão certo quando (a) você não precisa de tempo real, (b) você controla o data warehouse e só quer um lote diário/horário, ou (c) seu assinante está em uma rede que não aceita tráfego de entrada.
Para a maioria das equipes, webhooks são a resposta. A curva de novas tentativas lida com falhas transientes graciosamente; o esquema de assinatura lida com segurança; o modelo de idempotência lida com a duplicação de entrega. O trabalho é do lado do assinante — construir um manipulador robusto — e esse trabalho é pequeno em comparação com a construção de um pipeline de ingestão baseado em polling.
Ferramentas operacionais#
A página de webhook do painel mostra três coisas por assinatura:
- Histórico de entrega: cada evento enviado, o status HTTP que o assinante retornou, a latência e o próximo timestamp de nova tentativa (se houver).
- Replay: um botão por evento para reenviá-lo. Útil para testar alterações no seu manipulador.
- Endpoint de teste: um botão por assinatura para enviar um evento de teste sintético sem disparar um clique real. O evento de teste tem
type: "test"e um payload fixo.
Os endpoints de replay e teste também são expostos como endpoints de API (POST /v1/webhooks/{id}/events/{evt_id}/replay e POST /v1/webhooks/{id}/test).
Para depuração de alto throughput, o guia de observabilidade cobre como conectar a entrega de webhook às suas próprias métricas — cada despacho é exportado como um contador Prometheus e um histograma.
Referências externas#
- Cheat sheet da OWASP sobre segurança de webhook — a fundamentação do esquema de assinatura.
- Documentação de webhook da Stripe — a implementação de referência para webhooks assinados com HMAC.
- RFC 7234 — HTTP/1.1 Caching — cobre a semântica de
Retry-After.
Leitura relacionada#
- Explicação sobre links inteligentes — a pedra angular do cluster de recursos.
- Webhooks vs polling para rastreamento de cliques — quando escolher qual.
- URL shortener API + SDKs quickstart — a superfície de API de entrada.
- Ingestão de cliques fire-and-forget com Redpanda — a fila por trás do despachante.
- Rastreamento de conversão no lado do servidor — o que dispara o evento
conversion. - Superfícies de produto:
/features/webhooks,/solutions/developers. - Guia operacional:
/docs/guides/conversion-forwarding,/docs/guides/observability.