Implementar un Receptor de Webhook
Esta guía recorre la implementación de un receptor de webhook que:
- Verifica la signature HMAC.
- Deduplica por
event_id. - Retorna 200 rápidamente (en menos de 10 segundos).
- Encola el trabajo pesado para procesamiento async.
Arquitectura
- 1Recibir el POST
DVS envía un body JSON y headers de signature al endpoint.
- 2Verificar la signature HMAC
Rechazar con 401 si es inválida. Ver Verificar Signatures HMAC.
- 3Comprobar idempotency
Buscar
X-DVS-Event-Iden la tablaprocessed_events. Si existe, retornar 200 de inmediato sin reprocesar. - 4Insertar registro de idempotency + encolar
Insert atómico en
processed_events(unique constraint sobre event_id). Luego enviar el evento a la cola/worker interno. - 5Retornar 200 OK
En menos de 10 segundos. No ejecutar lógica de negocio de forma síncrona.
Por qué desacoplar de la lógica de negocio
DVS hace timeout en la entrega del webhook a los 10 segundos. Si el receptor ejecuta trabajo pesado de forma síncrona (consultas a base de datos, notificación a sistemas downstream, envío de correos), se corre el riesgo de timeout y DVS reintentará — generando procesamiento duplicado.
El patrón es: recibir, verificar, encolar, responder rápido. Un worker separado desencola y procesa a su propio ritmo.
Tabla de idempotency
CREATE TABLE processed_webhook_events (
event_id VARCHAR(255) PRIMARY KEY,
event_type VARCHAR(50) NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Insertar con semántica ON CONFLICT DO NOTHING. Si el insert retorna 0 filas, el evento ya fue procesado — responder 200 sin volver a encolar.
Limpiar registros con más de 30 días con un cron diario — DVS no reintenta eventos con más de 30 minutos de antigüedad de todos modos.
Receptor completo — Python (FastAPI)
import hmac
import hashlib
import time
from fastapi import FastAPI, Request, HTTPException, Header
from sqlalchemy import insert
from sqlalchemy.dialects.postgresql import insert as pg_insert
# Se asume que `db`, `events_table`, `internal_queue` ya están configurados.
DVS_WEBHOOK_SECRET = os.environ["DVS_WEBHOOK_SECRET"]
SIGNATURE_TOLERANCE_SECONDS = 300
def verify_signature(raw_body: bytes, sig: str, ts: str, secret: str) -> bool:
if not sig or not ts: return False
try: ts_int = int(ts)
except ValueError: return False
if abs(time.time() - ts_int) > SIGNATURE_TOLERANCE_SECONDS: return False
parts = {k: v for k, v in (kv.split("=", 1) for kv in sig.split(","))}
if "v1" not in parts: return False
expected = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(parts["v1"], expected)
def mark_processed(event_id: str, event_type: str) -> bool:
"""Retorna True si fue recién insertado (primera vez), False si ya existía."""
stmt = pg_insert(events_table).values(event_id=event_id, event_type=event_type).on_conflict_do_nothing()
result = db.execute(stmt)
return result.rowcount == 1
app = FastAPI()
@app.post("/integrations/dvs/webhooks")
async def receive(request: Request,
x_dvs_signature: str = Header(None),
x_dvs_signature_timestamp: str = Header(None),
x_dvs_event_id: str = Header(None),
x_dvs_event_type: str = Header(None)):
raw_body = await request.body()
if not verify_signature(raw_body, x_dvs_signature, x_dvs_signature_timestamp, DVS_WEBHOOK_SECRET):
raise HTTPException(401, "Invalid signature")
if not mark_processed(x_dvs_event_id, x_dvs_event_type):
return {"status": "duplicate_ignored"}
internal_queue.send_message(MessageBody=raw_body.decode(), MessageAttributes={
"event_type": {"StringValue": x_dvs_event_type, "DataType": "String"},
"event_id": {"StringValue": x_dvs_event_id, "DataType": "String"},
})
return {"status": "received"}
El worker (proceso separado) lee desde internal_queue, parsea el JSON y ejecuta la lógica de negocio.
Semántica del response code (qué hace DVS con la respuesta)
| Status retornado | Comportamiento de DVS |
|---|---|
2xx | Entregado. Sin retry. |
3xx | Tratado como error. Reintentado. DVS NO sigue redirects. |
4xx | Tratado como error. Reintentado hasta el máximo de intentos. |
5xx | Tratado como error. Reintentado. |
| Timeout (>10s) | Tratado como falla. Reintentado. |
Retornar 200 para eventos duplicados mantiene a DVS contento y previene retries innecesarios.
Qué hacer si el endpoint está caído
DVS reintentará hasta 3 veces (inmediato, +60s, +300s). Tras 3 fallas el evento va a la dead-letter queue de DVS y no se recupera automáticamente.
Fallback: hacer polling de GET /v1/classification-requests/{id} (o validation-requests) para los recursos cuyo webhook se perdió. El id también está en la response del POST original.
Tras 10 fallas consecutivas, DVS deshabilita automáticamente el endpoint. Contactar a OSIGU para reactivarlo.
Pruebas locales
Usar ngrok para exponer el servidor local. Ver Pruebas con ngrok.