Saltar al contenido principal

Implementar un Receptor de Webhook

Esta guía recorre la implementación de un receptor de webhook que:

  1. Verifica la signature HMAC.
  2. Deduplica por event_id.
  3. Retorna 200 rápidamente (en menos de 10 segundos).
  4. Encola el trabajo pesado para procesamiento async.

Arquitectura

  1. 1Recibir el POST

    DVS envía un body JSON y headers de signature al endpoint.

  2. 2Verificar la signature HMAC

    Rechazar con 401 si es inválida. Ver Verificar Signatures HMAC.

  3. 3Comprobar idempotency

    Buscar X-DVS-Event-Id en la tabla processed_events. Si existe, retornar 200 de inmediato sin reprocesar.

  4. 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.

  5. 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 retornadoComportamiento de DVS
2xxEntregado. Sin retry.
3xxTratado como error. Reintentado. DVS NO sigue redirects.
4xxTratado como error. Reintentado hasta el máximo de intentos.
5xxTratado 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.