Los webhooks son la parte de la superficie de la API de un acortador de URL que todo el mundo lanza y casi nadie lanza bien. Las partes difíciles no son la codificación — la carga útil es un objeto JSON — sino los detalles operativos: verificación de firma, política de reintento, idempotencia, garantías de entrega y qué sucede cuando el endpoint del suscriptor está fuera de servicio durante dos días.
Esta publicación documenta cada tipo de evento de webhook que emite Elido, cada forma de carga útil, la curva de reintento y el esquema de firma. La guía de inicio rápido de la API + SDK de acortador de URL cubre la superficie de la API entrante; este es el lado saliente.
Los 12 tipos de eventos#
Elido emite 12 tipos de eventos de webhook, agrupados en tres familias:
Eventos de clic y tráfico: click, bio.click, qr.scan, conversion. Estos se disparan en cada redirección o escaneo después de un pequeño retraso de cola (descrito a continuación).
Eventos de ciclo de vida: link.created, link.updated, link.deleted, bio.published. Estos se disparan desde la capa de API cuando el registro subyacente cambia.
Eventos de agregación y operaciones: daily.summary, campaign.ended, alert.threshold_exceeded, quota.warning. Estos se disparan según lo programado o al cruzar un umbral.
Un suscriptor registra un webhook en POST /v1/webhooks con una URL de destino y una matriz de tipos de eventos que desea recibir. La solicitud completa de suscripción:
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
}
El secret es la clave HMAC utilizada para firmar las solicitudes salientes. Es opaca para Elido; nunca la registramos ni la mostramos después de la respuesta a la llamada de creación.
La carga útil del evento de clic#
Por volumen, este es el evento que te importa. Cada redirección a través de cualquier enlace corto produce un evento click después de que la redirección se haya servido al cliente. 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"
}
Algunos detalles que vale la pena destacar:
ip_prefix, noip. Conservamos el prefijo de red /24 (IPv4) o /48 (IPv6), no la dirección completa. La publicación sobre GDPR para acortadores de URL cubre el porqué; el efecto práctico es que tu suscriptor obtiene suficiente precisión geográfica para el análisis sin la responsabilidad de datos personales de las IP completas.city_geoname_id, nocity_name. El ID de GeoNames es estable a través de las configuraciones regionales; el nombre de la ciudad varía. Si necesitas un nombre localizado, busca el ID en el volcado de GeoNames.org una vez y almacena en caché el resultado.user_agent_family, no la cadena completa de UA. Eliminamos la UA completa durante la ingesta (es un dato de huella digital de alta entropía); la familia es el navegador+versión mayor que sobrevive.
El retraso entre la redirección que sirve al cliente y el disparo del webhook es típicamente de 200ms a 2s. Los eventos de clic fluyen a través de Redpanda primero, se agregan para el análisis y luego un trabajador de distribución emite los webhooks. Esta es la misma tubería que impulsa el análisis del panel de control; la publicación sobre ingesta de clic de fuego y olvido cubre la mecánica de la cola.
La carga útil del evento de conversión#
Los eventos de conversión se disparan cuando un clic coincide con una conversión descendente: una compra, registro, formulario de cliente potencial o cualquier otra cosa que conectes a la tubería de reenvío de conversión.
{
"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"
}
El click_id se vincula al evento de clic original; puedes unir los dos en el lado del servidor para reconstruir la ruta del clic a la conversión. attribution_window_minutes es el tiempo transcurrido entre el clic y el disparo de la conversión, lo cual es útil para modelar la atribución.
La matriz forwarded_to te dice a qué píxeles de plataforma Elido ya ha enviado esta conversión. Si tu suscriptor está conectando conversiones a tu propio almacén de datos, puedes usar esto para evitar el conteo doble en tu análisis descendente.
La carga útil del evento link.created#
Los eventos de ciclo de vida tienen una forma más delgada: solo el recurso y el actor:
{
"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 incluye una instantánea previous junto al nuevo estado; link.deleted incluye el estado final del enlace en el momento de la eliminación. El esquema completo vive en la guía operativa /docs/guides/conversion-forwarding.
Verificación de firma#
Cada solicitud de webhook incluye tres encabezados HTTP:
Elido-Signature: t=1716392538,v1=4f3b2e1d9c8a7b6f5e4d3c2b1a0f9e8d7c6b5a49382716054 ...
Elido-Webhook-Id: evt_2g8kFqJxYwPaZcvAm3HsTr
Elido-Delivery-Attempt: 1
El esquema de firma sigue el modelo de Stripe: HMAC-SHA256 sobre {timestamp}.{body} usando el secreto del webhook. El prefijo v1= es la versión del algoritmo de firma; se añaden nuevas versiones de algoritmo antes de que se conviertan en predeterminadas, por lo que los suscriptores pueden verificar múltiples versiones a la vez.
Verificando en 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))
}
La verificación de inactividad de 5 minutos es la parte que la mayoría de los suscriptores olvidan. Sin ella, un ataque de repetición (un atacante que capturó una solicitud válida y la vuelve a enviar más tarde) tiene éxito porque la firma sigue siendo válida. Con la verificación de marca de tiempo, la solicitud solo se acepta dentro de una ventana de 5 minutos desde cuando Elido la emitió.
La especificación de firma está documentada en la hoja de trucos de OWASP sobre seguridad de webhooks; no inventamos el patrón, solo lo implementamos.
Política de reintento#
Esta es la parte donde la mayoría de las implementaciones de webhook se vuelven descuidadas.
Un webhook se dispara una vez en la ruta feliz: el suscriptor devuelve 2xx, el despachador registra el éxito, el evento está hecho. Los casos más difíciles son respuestas que no son 2xx, errores de red y suscriptores que responden lentamente.
El calendario de reintentos de Elido:
| Intento | Retraso tras el anterior | Acumulado | Estado |
|---|---|---|---|
| 1 | — | 0 | initial |
| 2 | 1s | 1s | first retry |
| 3 | 30s | 31s | |
| 4 | 5m | 5m 31s | |
| 5 | 1h | 1h 5m 31s | |
| 6 | 6h | 7h 5m 31s | |
| 7 | 24h | 31h 5m 31s | final |
Después del intento 7 (~31 horas después del primer intento), el despachador se rinde y emite un evento interno webhook.failed. El punto final del suscriptor se marca como degradado después de tres fallos consecutivos en cualquier evento; las suscripciones degradadas obtienen un presupuesto de reintento reducido durante 24 horas. Después de 50 fallos consecutivos, la suscripción se pausa y se notifica al propietario del espacio de trabajo.
El comportamiento de reintento respeta los encabezados Retry-After del suscriptor. Si tu punto final está limitando la tasa de Elido (devolviendo 429 con Retry-After: 120), el siguiente intento espera 120 segundos en lugar de los 30s predeterminados del programa.
La falta de respuesta en 10 segundos se trata como un tiempo de espera y cuenta como un intento fallido. El presupuesto de 10 segundos es generoso a propósito (cubre la latencia de arranque en frío de los suscriptores sin servidor), pero si tu punto final tarda regularmente más de 5 segundos, soluciona eso primero; te costará en volumen de reintento.
Idempotencia#
Los suscriptores pueden recibir el mismo evento más de una vez.
Esto no es un error; es la consecuencia de cómo funciona la entrega de mensajes distribuidos. Si un suscriptor devuelve un 504 porque su backend era lento pero eventualmente procesó el evento, el despachador reintentará; el suscriptor lo recibirá dos veces y podría procesarlo dos veces. El mismo evento también puede dispararse dos veces si el despachador se bloquea durante la entrega y el evento se vuelve a poner en cola.
La mitigación: cada evento tiene un id único (el prefijo evt_…). Los suscriptores deben almacenar los ID que ya han procesado (una pequeña tabla de clave-valor funciona; un TTL de 14 días cubre la ventana de reintento con margen) y omitir los eventos cuyo ID ya han visto antes.
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 es la verificación de idempotencia barata. Si el insert devuelve una fila, esta es la primera vez que has visto el evento; si no devuelve nada, ya lo has procesado.
Para suscriptores de alto rendimiento (>1k eventos/seg), un SETNX de Redis dedicado con TTL funciona de la misma manera a un costo menor que una fila de Postgres.
Orden de entrega#
No hay garantía de orden global. Los eventos del mismo link_id se envían en orden de envío, pero los eventos de diferentes enlaces pueden llegar entrelazados. Un evento click en el momento T+0 y un evento conversion en el momento T+10ms podrían llegar a tu suscriptor en cualquier orden dependiendo del estado del grupo de trabajadores.
Las marcas de tiempo created_at son autoritativas para el orden. Si tu suscriptor necesita un orden estricto, ordena por created_at en el lado del servidor antes de procesar.
Para la ruta clic → conversión específicamente: el evento de conversión siempre hace referencia al click_id del evento de clic, por lo que puedes unirlos en el lado del servidor incluso si llegan fuera de orden.
Webhooks frente a sondeo (polling): el equilibrio#
La publicación sobre webhooks frente a sondeo para el seguimiento de clics cubre esto en detalle. La respuesta corta: los webhooks son el patrón correcto cuando (a) necesitas baja latencia en la llegada del evento (<5 segundos), y (b) tu suscriptor es accesible desde la internet pública con TLS. El sondeo es el patrón correcto cuando (a) no necesitas tiempo real, (b) controlas el almacén de datos y solo quieres un lote diario/horario, o (c) tu suscriptor está en una red que no acepta tráfico entrante.
Para la mayoría de los equipos, los webhooks son la respuesta. La curva de reintento maneja fallos transitorios con gracia; el esquema de firma maneja la seguridad; el modelo de idempotencia maneja la duplicación de entrega. El trabajo está en el lado del suscriptor: construir un manejador robusto, y ese trabajo es pequeño comparado con la construcción de una tubería de ingesta basada en sondeo.
Herramientas operativas#
La página de webhooks del panel de control muestra tres cosas por suscripción:
- Historial de entrega: cada evento enviado, el estado HTTP que devolvió el suscriptor, la latencia y la marca de tiempo del próximo reintento (si lo hay).
- Reproducción (Replay): un botón por evento para reproducirlo. Útil para probar cambios en tu manejador.
- Punto final de prueba: un botón por suscripción para enviar un evento de prueba sintético sin disparar un clic real. El evento de prueba tiene
type: "test"y una carga útil fija.
Las funciones de reproducción y puntos finales de prueba también se exponen como puntos finales de API (POST /v1/webhooks/{id}/events/{evt_id}/replay y POST /v1/webhooks/{id}/test).
Para la depuración de alto rendimiento, la guía de observabilidad cubre cómo conectar la entrega de webhooks a tus propias métricas — cada envío se exporta como un contador de Prometheus y un histograma.
Referencias externas#
- Hoja de trucos de seguridad de webhooks de OWASP — la justificación del esquema de firma.
- Documentación de webhooks de Stripe — la implementación de referencia para webhooks firmados con HMAC.
- RFC 7234 — Caché HTTP/1.1 — cubre la semántica de
Retry-After.
Lecturas relacionadas#
- Explicación de enlaces inteligentes — la piedra angular del clúster de características.
- Webhooks frente a sondeo para el seguimiento de clics — cuándo elegir cuál.
- Inicio rápido de API + SDK de acortador de URL — la superficie de la API entrante.
- Ingesta de clic de fuego y olvido con Redpanda — la cola detrás del despachador.
- Seguimiento de conversión del lado del servidor — qué dispara el evento
conversion. - Superficies de producto:
/features/webhooks,/solutions/developers. - Guía operativa:
/docs/guides/conversion-forwarding,/docs/guides/observability.