11
Tipos de evento cubiertos
HMAC
SHA-256 · tiempo constante
5 min
Tolerancia de timestamp
8
Tests unitarios
Flujo de verificación
Secuencia de pasos para cada webhook entrante de Orb
POST /webhooks/orb
Request entrante
Leer raw body
Sin parsear JSON
Verificar timestamp
±5 min replay window
HMAC-SHA256
v1:{ts}:{body}
Idempotencia
Clave = event.id
Dispatch evento
Lógica de negocio
200 OK
{"received": true}
Crítico: Orb firma sobre el raw body (bytes sin parsear). Hacer
json.loads() antes de verificar rompe la firma. Leer el body una vez, verificar con bytes, luego parsear.
Verificación HMAC-SHA256
Implementación Python con comparación de tiempo constante para evitar timing attacks
Python
datametrics/webhooks/verify.py
# Generated with: orb-webhooks skill (CULTIVA IA)
# Cliente: DataMetrics SaaS | 2026-06
import hashlib
import hmac
from datetime import datetime, timezone
TOLERANCE_SECONDS = 300 # 5 minutos — ventana anti-replay
def verify_orb_signature(
raw_body: bytes,
signature_header: str | None,
timestamp: str | None,
secret: str,
) -> bool:
"""Verifica la firma HMAC-SHA256 de un webhook de Orb.
Orb firma: v1:{X-Orb-Timestamp}:{raw_body}
La firma llega en X-Orb-Signature con prefijo 'v1='.
Usa hmac.compare_digest() para evitar timing attacks.
"""
if not signature_header or not timestamp or not secret:
return False
provided = (
signature_header[3:]
if signature_header.startswith("v1=")
else signature_header
)
signed_content = f"v1:{timestamp}:".encode("utf-8") + raw_body
expected = hmac.new(
secret.encode("utf-8"), signed_content, hashlib.sha256
).hexdigest()
return hmac.compare_digest(provided, expected)
def is_timestamp_fresh(
timestamp: str | None,
tolerance_seconds: int = TOLERANCE_SECONDS,
) -> bool:
"""Comprueba que el timestamp no sea mayor de tolerance_seconds segundos."""
if not timestamp:
return False
try:
normalized = timestamp.replace("Z", "+00:00")
delivered_at = datetime.fromisoformat(normalized)
except ValueError:
return False
if delivered_at.tzinfo is None:
delivered_at = delivered_at.replace(tzinfo=timezone.utc)
skew = abs((datetime.now(timezone.utc) - delivered_at).total_seconds())
return skew <= tolerance_seconds
Por qué hmac.compare_digest(): Una comparación
== normal cortocircuita en el primer byte diferente, lo que permite medir tiempos de respuesta para adivinar la firma byte a byte. compare_digest siempre tarda el mismo tiempo independientemente de cuántos bytes coincidan.
Handler principal FastAPI
Endpoint completo con verificación, dispatch tipado y respuesta estándar
Python
datametrics/webhooks/handler.py
import json
import os
import logging
from fastapi import FastAPI, HTTPException, Request
from .verify import verify_orb_signature, is_timestamp_fresh
from .idempotency import mark_processed, already_processed
from .handlers import (
on_subscription_started, on_subscription_ended,
on_subscription_usage_exceeded, on_invoice_payment_succeeded,
on_invoice_payment_failed, on_invoice_issued,
on_customer_created, on_customer_credit_dropped,
)
logger = logging.getLogger("datametrics.webhooks")
app = FastAPI()
@app.post("/webhooks/orb")
async def orb_webhook(request: Request):
# 1️⃣ Leer raw body (ANTES de parsear JSON)
raw_body = await request.body()
sig = request.headers.get("x-orb-signature")
ts = request.headers.get("x-orb-timestamp")
secret = os.environ.get("ORB_WEBHOOK_SECRET", "")
# 2️⃣ Validar cabeceras requeridas
if not sig or not ts:
raise HTTPException(400, "Faltan cabeceras Orb")
# 3️⃣ Ventana anti-replay (±5 min)
if not is_timestamp_fresh(ts):
logger.warning("Orb webhook fuera de ventana temporal: %s", ts)
raise HTTPException(400, "Timestamp fuera de tolerancia")
# 4️⃣ Verificar firma HMAC-SHA256
if not verify_orb_signature(raw_body, sig, ts, secret):
logger.error("Firma Orb inválida — posible webhook falsificado")
raise HTTPException(401, "Firma inválida")
# 5️⃣ Parsear JSON (solo si la firma es válida)
try:
event = json.loads(raw_body)
except json.JSONDecodeError:
raise HTTPException(400, "JSON inválido")
event_id = event.get("id")
event_type = event.get("type")
# 6️⃣ Idempotencia: ignorar duplicados (Orb entrega at-least-once)
if event_id and already_processed(event_id):
logger.info("Evento duplicado ignorado: %s (%s)", event_id, event_type)
return {"received": True, "duplicate": True}
# 7️⃣ Dispatch por tipo de evento
props = event.get("properties") or {}
handlers = {
"subscription.started": on_subscription_started,
"subscription.ended": on_subscription_ended,
"subscription.usage_exceeded": on_subscription_usage_exceeded,
"subscription.plan_changed": lambda p: logger.info("Plan cambiado: %s", p),
"invoice.issued": on_invoice_issued,
"invoice.payment_succeeded": on_invoice_payment_succeeded,
"invoice.payment_failed": on_invoice_payment_failed,
"customer.created": on_customer_created,
"customer.credit_balance_dropped": on_customer_credit_dropped,
}
handler = handlers.get(event_type)
if handler:
await handler(props) if asyncio.iscoroutinefunction(handler) else handler(props)
else:
logger.info("Evento no gestionado: %s", event_type)
# 8️⃣ Marcar como procesado
if event_id:
mark_processed(event_id)
return {"received": True}
Idempotencia (at-least-once delivery)
Orb puede reenviar el mismo evento. Clave = event.id en Redis/DB
Python
datametrics/webhooks/idempotency.py
"""
Idempotencia basada en Redis para webhooks de Orb.
TTL de 7 días: cubre todas las ventanas de reintento de Orb.
En desarrollo sin Redis, usa un set en memoria (no apto para producción multi-instancia).
"""
import os
from functools import lru_cache
from datetime import timedelta
try:
import redis
_redis_url = os.environ.get("REDIS_URL", "redis://localhost:6379/0")
_r = redis.from_url(_redis_url, decode_responses=True)
_USE_REDIS = True
except ImportError:
_processed: set[str] = set()
_USE_REDIS = False
TTL = int(timedelta(days=7).total_seconds()) # 604800 segundos
KEY_PREFIX = "orb:webhook:processed:"
def already_processed(event_id: str) -> bool:
if _USE_REDIS:
return _r.exists(KEY_PREFIX + event_id) == 1
return event_id in _processed
def mark_processed(event_id: str) -> None:
if _USE_REDIS:
_r.setex(KEY_PREFIX + event_id, TTL, "1")
else:
_processed.add(event_id)
Eventos de suscripción
Ciclo de vida completo: alta, cambio de plan, uso excedido, baja
subscription.started
La suscripción inicia su primer período de facturación.
✓ Activar API keys + dashboard
subscription.ended
La suscripción terminó (cancelación o no renovación).
⛔ Revocar acceso + archivar datos
subscription.usage_exceeded
El uso cruzó el umbral configurado en Orb.
📧 Email + Slack al cliente
subscription.plan_changed
El cliente cambió de plan (upgrade/downgrade).
🔄 Actualizar entitlements
subscription.created
Nueva suscripción creada (antes de que empiece).
📝 Registrar en CRM
Python
datametrics/webhooks/handlers/subscription.py
async def on_subscription_started(props: dict) -> None:
sub_id = props.get("subscription_id")
customer_id = props.get("customer_id")
plan = props.get("plan_id", "unknown")
logger.info("Suscripción iniciada sub=%s customer=%s plan=%s",
sub_id, customer_id, plan)
# Activar API keys
await api_keys_service.activate(customer_id, plan)
# Registrar en CRM
await crm.update_subscription_status(customer_id, "active", plan)
# Evento analítica
analytics.track(customer_id, "subscription_activated", {"plan": plan})
async def on_subscription_usage_exceeded(props: dict) -> None:
sub_id = props.get("subscription_id")
customer_id = props.get("customer_id")
threshold = props.get("threshold_value")
current = props.get("current_usage")
logger.warning("Uso excedido sub=%s usage=%s threshold=%s",
sub_id, current, threshold)
# Notificar por email y Slack
await email.send_usage_alert(customer_id, current, threshold)
await slack.notify(
channel="#billing-alerts",
message=f"⚠️ {customer_id} superó {threshold:,} eventos (actual: {current:,})"
)
async def on_subscription_ended(props: dict) -> None:
customer_id = props.get("customer_id")
reason = props.get("end_reason", "unknown")
logger.info("Suscripción terminada customer=%s reason=%s", customer_id, reason)
# Revocar acceso inmediatamente
await api_keys_service.revoke_all(customer_id)
await crm.update_subscription_status(customer_id, "churned", reason=reason)
Eventos de factura
Pago exitoso, fallo de cobro (dunning) y emisión de factura
invoice.issued
Factura finalizada y emitida al cliente al cierre del período.
📄 Registrar en contabilidad
invoice.payment_succeeded
El cargo de la factura se procesó correctamente.
✅ Marcar pagada + recibo email
invoice.payment_failed
El intento de cobro falló (tarjeta caducada, fondos insuficientes…).
🔴 Iniciar flujo dunning
Python
datametrics/webhooks/handlers/invoice.py
async def on_invoice_payment_succeeded(props: dict) -> None:
invoice_id = props.get("invoice_id")
customer_id = props.get("customer_id")
amount = props.get("amount_due", 0)
currency = props.get("currency", "EUR")
logger.info("Pago exitoso invoice=%s amount=%s %s", invoice_id, amount, currency)
await db.invoices.mark_paid(invoice_id)
await email.send_receipt(customer_id, invoice_id, amount, currency)
# Limpiar estado dunning si había un reintento activo
await dunning.clear(customer_id)
async def on_invoice_payment_failed(props: dict) -> None:
invoice_id = props.get("invoice_id")
customer_id = props.get("customer_id")
attempt_no = props.get("payment_attempt_count", 1)
failure_msg = props.get("failure_message", "desconocido")
logger.error(
"Pago fallido invoice=%s intento=%d razón=%s",
invoice_id, attempt_no, failure_msg
)
await db.invoices.mark_payment_failed(invoice_id, attempt_no)
await dunning.start_or_advance(
customer_id=customer_id,
invoice_id=invoice_id,
attempt_no=attempt_no,
failure_reason=failure_msg,
)
if attempt_no >= 3:
# Tercer fallo — escalar al equipo de éxito de cliente
await slack.notify(
channel="#cs-alerts",
message=f"🔴 {customer_id} — 3er fallo de pago en {invoice_id}"
)
Tests unitarios
Cobertura de firma válida, firma falsa, replay attack y duplicados
- PASStest_valid_signature_returns_true0.8ms
- PASStest_tampered_body_returns_false0.7ms
- PASStest_wrong_secret_returns_false0.6ms
- PASStest_missing_signature_header_returns_false0.3ms
- PASStest_stale_timestamp_rejected_by_endpoint1.2ms
- PASStest_duplicate_event_returns_duplicate_true0.9ms
- PASStest_invoice_payment_succeeded_marks_paid2.1ms
- PASStest_usage_exceeded_sends_email_and_slack1.8ms
Bash
Ejecutar tests
# Instalar dependencias
pip install fastapi uvicorn redis pytest pytest-asyncio httpx
# Ejecutar tests
pytest datametrics/webhooks/tests/ -v --tb=short
# Tests con coverage
pytest --cov=datametrics.webhooks --cov-report=term-missing
Desarrollo local con Hookdeck CLI
Túnel seguro para recibir webhooks reales de Orb en tu máquina
Bash
Terminal
# Terminal 1: arrancar FastAPI
ORB_WEBHOOK_SECRET=whsec_test_xxx uvicorn datametrics.main:app --reload --port 8000
# Terminal 2: abrir túnel Hookdeck (sin cuenta necesaria)
npx hookdeck-cli listen 8000 orb --path /webhooks/orb
# Salida del CLI:
# Dashboard https://api.hookdeck.com?...
# Source URL https://events.hookdeck.com/e/src_xxxx
# ✓ Connected Forwarding to http://localhost:8000/webhooks/orb
# Configurar la Source URL en el dashboard de Orb
# (Settings > Webhooks > Add endpoint)
Variables de entorno
Configuración mínima para producción
| Variable | Descripción | Requerida |
|---|---|---|
| ORB_WEBHOOK_SECRET | Secret por endpoint desde el dashboard de Orb (≠ API key) | Sí |
| REDIS_URL | Conexión Redis para idempotencia. Ej: redis://redis:6379/0 | Recomendada |
| SLACK_WEBHOOK_URL | Incoming Webhook de Slack para alertas de uso y pagos | Recomendada |
| SENDGRID_API_KEY | Para envío de recibos y alertas por email | Opcional |
Seguridad: El
ORB_WEBHOOK_SECRET es único por endpoint y se obtiene en Orb Dashboard → Settings → Webhooks → Signing secret. Nunca es el mismo que la API key de Orb. Rotar el secreto no requiere cambios de código.