Webhooks
Suscribirse a eventos de LealUp — entrega, reintentos, firmas HMAC y protección anti-replay.
Los webhooks permiten que tu sistema reciba notificaciones en tiempo real cuando pasan cosas en LealUp — sin tener que hacer polling.
Casos de uso típicos
- Sincronizar salud a tu CRM — cuando un cliente cae a rojo, marca el deal en Salesforce/HubSpot.
- Alertas custom — mandar un mensaje a tu canal de incidentes cuando hay churn signal.
- Integrar con billing — cuando un cliente cambia de tier, actualizar tu sistema de facturación.
- Data warehouse — replicar eventos de LealUp a Snowflake/BigQuery.
Eventos disponibles
Una subset de los eventos que puedes suscribir:
| Evento | Descripción |
|---|---|
customer.created | nuevo cliente creado |
customer.updated | cambio en ARR, owner, status, campos |
customer.deleted | cliente eliminado (soft-delete) |
customer.status_changed | cambio a churned, paused, etc. |
health.changed | score cruzó umbral (saludable→neutro, neutro→riesgo, etc.) |
health.drop_sharp | caída >15 pts en 7 días |
renewal.approaching | renewal en 60/30/15/7 días |
renewal.completed | cliente renovó |
renewal.churned | cliente no renovó |
task.created | tarea creada (por playbook o manual) |
task.completed | tarea completada |
task.overdue | tarea vencida |
playbook.triggered | playbook se disparó |
ai.detected_issue | La IA detectó un issue potencial en WhatsApp/email |
integration.disconnected | una integración dejó de funcionar |
Lista completa: GET /v1/webhook-events.
Crear una suscripción
POST /v1/webhook-subscriptions
Authorization: Bearer ...
Content-Type: application/json{
"url": "https://tu-sistema.com/lealup-webhook",
"events": [
"health.drop_sharp",
"renewal.approaching"
],
"secret": "wh_secret_abc123...",
"description": "Alertas a Slack via Zapier",
"filters": {
"segment_id": "01HSEG..."
}
}Respuesta:
{
"id": "01HWEB...",
"url": "...",
"events": [...],
"created_at": "2026-04-15T...",
"status": "active"
}Campos
url— HTTPS obligatorio. Debe responder2xxen <5 seg.events— lista de tipos (o["*"]para todos).secret— para firmar requests. Mínimo 32 chars. Lo genera tu sistema; LealUp lo almacena para firmar.filters— opcional, para suscripciones más granulares (solo clientes de X segmento, con ARR > Y, etc.).
Formato del payload
POST https://tu-sistema.com/lealup-webhook
Content-Type: application/json
X-LealUp-Signature: sha256=abc123def456...
X-LealUp-Timestamp: 1713193200
X-LealUp-Event: health.drop_sharp
X-LealUp-Delivery-Id: 01HDEL...
{
"event": "health.drop_sharp",
"delivery_id": "01HDEL...",
"created_at": "2026-04-15T14:30:00Z",
"tenant_id": "01HTEN...",
"data": {
"customer_id": "01HCUS...",
"customer_name": "Acme Corp",
"previous_score": 72,
"current_score": 54,
"delta": -18,
"days": 5,
"owner_id": "01HUSR...",
"owner_email": "[email protected]"
}
}Verificar la firma (obligatorio)
Siempre valida el header X-LealUp-Signature para confirmar que el request viene de LealUp y no fue tampered.
Algoritmo
signature = HMAC-SHA256(
secret,
timestamp + "." + raw_body
)Ejemplo (Node.js)
import crypto from "crypto";
function verifyWebhook(
rawBody: string,
signature: string,
timestamp: string,
secret: string,
): boolean {
// 1. Verifica que el timestamp sea reciente (anti-replay).
const age = Date.now() / 1000 - parseInt(timestamp);
if (age > 300) return false; // >5 min → rechaza
// 2. Calcula la firma esperada.
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
// 3. Compara con time-safe equals.
const received = signature.replace("sha256=", "");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(received),
);
}Ejemplo (Python)
import hmac
import hashlib
import time
def verify_webhook(raw_body: bytes, signature: str, timestamp: str, secret: str) -> bool:
if abs(time.time() - int(timestamp)) > 300:
return False
expected = hmac.new(
secret.encode(),
f"{timestamp}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
received = signature.replace("sha256=", "")
return hmac.compare_digest(expected, received)Entrega y reintentos
- Timeout: tu endpoint debe responder en 5 segundos.
- Success: cualquier status
2xxcuenta como delivered. - Retry: si falla (timeout, 5xx, conexión rota), reintentamos con backoff exponencial:
- Intento 1: inmediato.
- Intento 2: +1 min.
- Intento 3: +5 min.
- Intento 4: +30 min.
- Intento 5: +2 h.
- Intento 6: +12 h.
- Intento 7 (último): +24 h.
- Dead letter: después de 7 intentos fallidos, el delivery se marca como
failedy se loguea. Tu suscripción no se desactiva; seguirá recibiendo nuevos eventos.
Idempotencia y orden
Idempotencia
Cada delivery tiene X-LealUp-Delivery-Id único. Guarda los que ya procesaste — en caso de reintento, LealUp reenvía con el mismo delivery_id.
if (await redis.exists(`processed:${deliveryId}`)) {
return { status: 200 }; // ya lo procesamos, OK
}
await processWebhook(payload);
await redis.set(`processed:${deliveryId}`, "1", { ex: 86400 });Orden
LealUp no garantiza orden estricto (entrega at-least-once). Si recibes customer.updated y luego customer.created fuera de orden, usa el created_at del payload para resolver.
Filtros
Puedes limitar cuándo una suscripción dispara:
{
"url": "...",
"events": ["health.changed"],
"filters": {
"segment_id": "01HSEG...",
"min_arr": 50000,
"from_status": ["healthy", "neutral"],
"to_status": "at_risk"
}
}Filtros soportados dependen del evento. Ver GET /v1/webhook-events/{event}/filters.
Gestionar suscripciones
GET /v1/webhook-subscriptions # lista
GET /v1/webhook-subscriptions/{id} # detalle + stats (deliveries último 7d)
PATCH /v1/webhook-subscriptions/{id} # update URL, events, secret
DELETE /v1/webhook-subscriptions/{id} # eliminarVer deliveries
GET /v1/webhook-subscriptions/{id}/deliveriesQuery: status (success | failed | pending), from, to.
Respuesta incluye request headers, body, response status, latency — útil para debug.
Replay
Si tu endpoint estuvo caído y quieres reprocesar:
POST /v1/webhook-subscriptions/{id}/deliveries/{delivery_id}/replayUI
También puedes gestionar suscripciones desde Admin → API → Webhooks:
- Crear / editar / eliminar.
- Ver deliveries recientes con response.
- Test endpoint (LealUp manda un evento de prueba).
Seguridad
- HTTPS obligatorio — endpoints HTTP son rechazados.
- Firma HMAC — siempre verifica (previene spoofing).
- Timestamp check — rechaza requests >5 min (previene replay).
- Rotación de secret —
PATCH .../secret— LealUp firma con el nuevo secret inmediatamente; tú tienes que actualizar tu endpoint a aceptar el nuevo. - IP allowlist — opcional, pide rango de IPs de LealUp a soporte (
[email protected]).
Límites
- Max 50 suscripciones por workspace.
- Max 100 requests/seg entrantes a tu endpoint (burst).
- Max retention de deliveries logs: 30 días (90 en Scale+).
Problemas comunes
"Estoy recibiendo eventos duplicados"
- Normal — at-least-once delivery. Usa
delivery_idpara idempotencia.
"No recibo nada"
- Verifica URL en
GET /v1/webhook-subscriptions/{id}. - Chequea deliveries:
GET .../deliveries?status=failedpara ver si están fallando. - Test manual: Admin → API → Webhooks → [sub] → Test.
- Firewalls/VPN — LealUp conecta desde IPs fijas (pide allowlist a soporte).
"Firma no valida"
- Timestamp skew — tu server y LealUp deben estar sincronizados (<5 min).
- No estás usando el raw body — muchos frameworks parsean JSON automáticamente. Usa el body original como bytes.
- Secret incorrecto — verifica que tu secret almacenado coincide con el que LealUp tiene.
"Llegan muy tarde"
- Si tu endpoint toma >2 seg, el throughput baja (el worker espera). Responde
202 Acceptedrápido y procesa async.
Ejemplo completo (Python + FastAPI)
from fastapi import FastAPI, Request, HTTPException
import hmac, hashlib, time
app = FastAPI()
SECRET = os.environ["LEALUP_WEBHOOK_SECRET"]
@app.post("/lealup-webhook")
async def webhook(request: Request):
body = await request.body()
sig = request.headers.get("x-lealup-signature", "")
ts = request.headers.get("x-lealup-timestamp", "0")
if abs(time.time() - int(ts)) > 300:
raise HTTPException(401, "Stale timestamp")
expected = hmac.new(
SECRET.encode(),
f"{ts}.".encode() + body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, sig.replace("sha256=", "")):
raise HTTPException(401, "Invalid signature")
payload = await request.json()
delivery_id = payload["delivery_id"]
if await already_processed(delivery_id):
return {"status": "already_processed"}
await process_event(payload["event"], payload["data"])
await mark_processed(delivery_id)
return {"status": "ok"}