Ejemplos
Implementaciones completas de handlers: verificación de firma, idempotencia, procesamiento asíncrono y observabilidad. Elegí la que corresponda a tu stack.
Las cuatro siguen la misma estructura, y vale la pena nombrar las partes antes de leer el código:
- Verificar la firma sobre los bytes crudos. Rechazar con
401si falla. - Deduplicar por
payload.id. La entrega es al menos una vez. - Persistir y después confirmar. La fila que escribís es la confirmación; el trabajo pasa después.
- Enrutar por
payload.type. No porevent.
Estos ejemplos identifican los registros de usuario por userRef, que es el valor que pasaste a /v1/verify/init. Es null si no pasaste ninguno — en ese caso guardá vos mismo el mapeo verificationId → usuario en el momento del init y buscalo acá. No hay otro campo de correlación: el metadata que mandás a /submit no vuelve en el evento.
Node.js / Express + Postgres + BullMQ
import express from 'express';
import crypto from 'node:crypto';
import { Pool } from 'pg';
import { Queue, Worker } from 'bullmq';
const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const connection = { host: process.env.REDIS_HOST };
const verificationQueue = new Queue('verifications', { connection });
const VERIDIA_SECRET = process.env.VERIDIA_WEBHOOK_SECRET;
app.post(
'/webhooks/veridia',
express.raw({ type: 'application/json' }),
async (req, res) => {
const sigHeader = req.header('Veridia-Signature') || '';
const rawBody = req.body;
// 1. Verificar la firma sobre los bytes crudos
if (!verifyVeridiaSignature(sigHeader, rawBody, VERIDIA_SECRET)) {
console.warn('Invalid Veridia signature', {
eventId: req.header('Veridia-Event-Id'),
});
return res.status(401).send('Invalid signature');
}
const payload = JSON.parse(rawBody.toString('utf8'));
// 2. Idempotencia. `id` es el valor evt_*: estable a lo largo de los seis
// intentos de reintento de un evento, distinto entre eventos. Una clave
// armada con verificationId colapsaría el evento review_required de la
// máquina y la aprobación posterior del revisor en uno solo, y la
// aprobación — que llega segunda — sería la que se tira.
const eventId = payload.id;
// 3. Persistir. ON CONFLICT hace que el propio insert sea el chequeo de
// deduplicación, así dos reintentos concurrentes no pueden pasar ambos
// por un SELECT separado.
const inserted = await pool.query(
`INSERT INTO webhook_log (event_id, event_type, verification_id, payload, received_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id`,
[eventId, payload.type, payload.verificationId, payload]
);
if (inserted.rowCount === 0) {
return res.status(200).send('ok'); // ya visto
}
// 4. Pasá el trabajo a almacenamiento durable ANTES de confirmar.
// El orden importa y el orden tentador es el equivocado. Responder 200
// primero parece más rápido, pero si el proceso muere en el hueco la
// fila de deduplicación ya está commiteada y a Veridia ya se le dijo
// "recibido" — así que nunca reintenta, y ese veredicto se perdió sin
// dejar rastro. `jobId: eventId` hace idempotente el propio encolado,
// así que un reintento que llegue dos veces acá igual produce un job.
await verificationQueue.add(payload.type, payload, {
jobId: eventId,
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
});
// 5. Recién ahora confirmá. El trabajo pesado va en el worker, no acá:
// el timeout de entrega de Veridia es de 10 segundos.
res.status(200).send('ok');
}
);
function verifyVeridiaSignature(header, rawBody, secret) {
const parts = {};
for (const piece of header.split(',')) {
const idx = piece.indexOf('=');
if (idx > 0) parts[piece.slice(0, idx).trim()] = piece.slice(idx + 1).trim();
}
const timestamp = parseInt(parts.t, 10);
const receivedSig = parts.v1;
if (!timestamp || !receivedSig) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > 300) return false;
const signedPayload = Buffer.concat([
Buffer.from(`${timestamp}.`, 'utf8'),
rawBody,
]);
const expectedSig = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
// Chequeo de longitud primero: timingSafeEqual lanza RangeError con
// longitudes desiguales, convirtiendo una firma corta falsificada en un
// 500 en vez de un 401.
const expectedBuf = Buffer.from(expectedSig, 'utf8');
const receivedBuf = Buffer.from(receivedSig, 'utf8');
if (expectedBuf.length !== receivedBuf.length) return false;
return crypto.timingSafeEqual(expectedBuf, receivedBuf);
}
// Worker: procesa las verificaciones de forma asíncrona
const worker = new Worker('verifications', async (job) => {
const payload = job.data;
switch (payload.type) {
case 'verification.approved':
return handleApproved(payload);
case 'verification.rejected':
return handleRejected(payload);
case 'verification.review_required':
return handleReviewRequired(payload);
default:
// Logueá y devolvé éxito. Lanzar acá reintentaría un job que nunca
// puede pasar.
console.warn('Unknown Veridia event type', {
type: payload.type,
eventId: payload.id,
});
}
}, { connection });
async function handleApproved(payload) {
// createdAt está en SEGUNDOS unix. No existe completedAt en el payload.
await pool.query(
`UPDATE users SET kyc_status = 'verified', kyc_completed_at = to_timestamp($1)
WHERE user_ref = $2`,
[payload.createdAt, payload.userRef]
);
await sendWelcomeEmail(payload.userRef);
}
async function handleRejected(payload) {
await pool.query(
`UPDATE users SET kyc_status = 'rejected' WHERE user_ref = $1`,
[payload.userRef]
);
await sendGenericFailureEmail(payload.userRef); // nunca expongas las flags
}
async function handleReviewRequired(payload) {
await pool.query(
`UPDATE users SET kyc_status = 'pending_review' WHERE user_ref = $1`,
[payload.userRef]
);
await notifyReviewers({
verificationId: payload.verificationId,
flags: payload.flags,
// Los niveles son ok / warn / err. No existe 'critical'.
priority: payload.flags.some(f => f.level === 'err') ? 'high' : 'normal',
});
}
app.listen(3000, () => console.log('Webhook server listening on :3000'));
Esquema:
CREATE TABLE webhook_log (
event_id VARCHAR(64) PRIMARY KEY, -- payload.id (evt_*)
event_type VARCHAR(64) NOT NULL, -- payload.type
verification_id VARCHAR(64) NOT NULL,
payload JSONB NOT NULL,
received_at TIMESTAMPTZ DEFAULT NOW(),
processed_at TIMESTAMPTZ
);
CREATE INDEX idx_webhook_log_verification ON webhook_log(verification_id);
CREATE INDEX idx_webhook_log_received_at ON webhook_log(received_at);
event_id es la clave primaria, y verification_id solo está indexado. Una verificación produce legítimamente varios eventos — un review_required seguido del approved del revisor — así que hacer único a verification_id rechazaría justo la decisión que importa.
La columna payload ahora contiene fieldsExtracted: nombre completo, número de documento, fecha de nacimiento. Cualquier política de retención y acceso que cubra datos personales en tu empresa cubre esta tabla.
Python / FastAPI + SQLAlchemy + Celery
import hmac
import hashlib
import time
import os
import json
from fastapi import FastAPI, Request, HTTPException, Depends
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from .database import get_db
from .models import WebhookLog
from .tasks import process_verification
app = FastAPI()
VERIDIA_SECRET = os.environ["VERIDIA_WEBHOOK_SECRET"].encode()
def verify_signature(header: str, raw_body: bytes, secret: bytes) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
timestamp_str = parts.get("t")
received_sig = parts.get("v1")
if not timestamp_str or not received_sig:
return False
try:
timestamp = int(timestamp_str)
except ValueError:
return False
if abs(time.time() - timestamp) > 300:
return False
signed_payload = f"{timestamp}.".encode() + raw_body
expected_sig = hmac.new(secret, signed_payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected_sig, received_sig)
@app.post("/webhooks/veridia")
async def veridia_webhook(request: Request, db: AsyncSession = Depends(get_db)):
sig_header = request.headers.get("veridia-signature", "")
raw_body = await request.body()
if not verify_signature(sig_header, raw_body, VERIDIA_SECRET):
raise HTTPException(status_code=401, detail="Invalid signature")
payload = json.loads(raw_body)
# .get(), no payload["..."]: un KeyError acá se convierte en un 500, y el
# despachador lo reintentaría seis veces antes de dejar el evento como failed.
event_id = payload.get("id")
event_type = payload.get("type")
if not event_id or not event_type:
# Malformado para nosotros, pero no es algo que un reintento arregle.
# 2xx y a loguear.
return {"ok": True, "ignored": "missing id or type"}
# Idempotencia: que decida la restricción unique, no un SELECT previo.
stmt = (
pg_insert(WebhookLog.__table__)
.values(
event_id=event_id,
event_type=event_type,
verification_id=payload["verificationId"],
payload=payload,
)
.on_conflict_do_nothing(index_elements=["event_id"])
.returning(WebhookLog.__table__.c.event_id)
)
result = await db.execute(stmt)
await db.commit()
if result.scalar_one_or_none() is None:
return {"ok": True} # entrega duplicada
process_verification.delay(payload)
return {"ok": True}
# tasks.py
import os
from celery import Celery
celery = Celery("veridia", broker=os.environ["REDIS_URL"])
@celery.task(bind=True, max_retries=3, default_retry_delay=5)
def process_verification(self, payload: dict):
try:
event_type = payload["type"]
if event_type == "verification.approved":
handle_approved(payload)
elif event_type == "verification.rejected":
handle_rejected(payload)
elif event_type == "verification.review_required":
handle_review_required(payload)
else:
log.warning("unknown_veridia_event", type=event_type, id=payload["id"])
except Exception as exc:
raise self.retry(exc=exc)
def handle_approved(payload: dict):
from datetime import datetime, timezone
# createdAt está en segundos unix; no existe completedAt.
completed_at = datetime.fromtimestamp(payload["createdAt"], tz=timezone.utc)
update_user_kyc(payload["userRef"], "verified", completed_at)
send_welcome_email(payload["userRef"])
def handle_review_required(payload: dict):
update_user_kyc(payload["userRef"], "pending_review", None)
flags = payload.get("flags") or []
priority = "high" if any(f["level"] == "err" for f in flags) else "normal"
# scores["liveness"] es nulleable — comparar None con un número lanza
# TypeError en Python, lo que haría fallar la tarea en vez del chequeo.
liveness = (payload.get("scores") or {}).get("liveness")
weak_liveness = liveness is not None and liveness < 50
enqueue_for_review(
payload["verificationId"],
priority=priority,
weak_liveness=weak_liveness,
)
Notá la diferencia con JavaScript: payload["event"] sobre una clave inexistente lanza KeyError, así que un handler escrito contra el nombre de campo equivocado devuelve 500 y el evento se reintenta seis veces y después queda estacionado como fallido. Ruidoso en vez de silencioso — pero el veredicto igual se pierde hasta que alguien lo vuelva a encolar.
PHP / Laravel
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use App\Jobs\ProcessVeridiaWebhook;
class VeridiaWebhookController
{
public function handle(Request $request)
{
$sigHeader = $request->header('Veridia-Signature', '');
$rawBody = $request->getContent();
$secret = config('services.veridia.webhook_secret');
// 1. Verificar
if (!$this->verifySignature($sigHeader, $rawBody, $secret)) {
Log::warning('Invalid Veridia signature', [
'event_id' => $request->header('Veridia-Event-Id'),
]);
return response()->json(['error' => 'Invalid signature'], 401);
}
$payload = json_decode($rawBody, true);
$eventId = $payload['id'] ?? null;
$eventType = $payload['type'] ?? null;
if ($eventId === null || $eventType === null) {
// No es reintentable. Confirmar para no quemar la ventana de reintentos.
Log::warning('Veridia webhook missing id or type');
return response()->json(['ok' => true]);
}
// 2. Idempotencia: insertOrIgnore contra la clave primaria, para que dos
// reintentos concurrentes no puedan pasar ambos el chequeo.
$inserted = DB::table('webhook_log')->insertOrIgnore([
'event_id' => $eventId,
'event_type' => $eventType,
'verification_id' => $payload['verificationId'],
'payload' => json_encode($payload),
'received_at' => now(),
]);
if ($inserted === 0) {
return response()->json(['ok' => true]); // duplicado
}
// 3. Encolar
ProcessVeridiaWebhook::dispatch($payload);
return response()->json(['ok' => true]);
}
private function verifySignature(string $header, string $rawBody, string $secret): bool
{
$parts = [];
foreach (explode(',', $header) as $p) {
$kv = explode('=', $p, 2);
if (count($kv) === 2) {
$parts[trim($kv[0])] = trim($kv[1]);
}
}
$timestamp = (int)($parts['t'] ?? 0);
$receivedSig = $parts['v1'] ?? '';
if ($timestamp === 0 || $receivedSig === '') {
return false;
}
if (abs(time() - $timestamp) > 300) {
return false;
}
$signedPayload = $timestamp . '.' . $rawBody;
$expectedSig = hash_hmac('sha256', $signedPayload, $secret);
return hash_equals($expectedSig, $receivedSig);
}
}
<?php
// app/Jobs/ProcessVeridiaWebhook.php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use App\Models\User;
class ProcessVeridiaWebhook implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 5;
public function __construct(public array $payload) {}
public function handle(): void
{
match ($this->payload['type']) {
'verification.approved' => $this->handleApproved(),
'verification.rejected' => $this->handleRejected(),
'verification.review_required' => $this->handleReviewRequired(),
default => Log::warning('Unknown Veridia event', [
'type' => $this->payload['type'],
'event_id' => $this->payload['id'],
]),
};
}
private function handleApproved(): void
{
User::where('user_ref', $this->payload['userRef'])->update([
'kyc_status' => 'verified',
// createdAt está en segundos unix. No existe completedAt.
'kyc_completed_at' => now()->setTimestamp($this->payload['createdAt']),
]);
}
private function handleRejected(): void
{
User::where('user_ref', $this->payload['userRef'])->update([
'kyc_status' => 'rejected',
]);
}
private function handleReviewRequired(): void
{
User::where('user_ref', $this->payload['userRef'])->update([
'kyc_status' => 'pending_review',
]);
$hasHardFailure = collect($this->payload['flags'])
->contains(fn ($f) => $f['level'] === 'err');
ReviewQueue::push(
$this->payload['verificationId'],
$hasHardFailure ? 'high' : 'normal'
);
}
}
Ruta (routes/api.php):
Route::post('/webhooks/veridia', [VeridiaWebhookController::class, 'handle']);
Importante: excluí la ruta del webhook de la protección CSRF. Agregá webhooks/* a $except en app/Http/Middleware/VerifyCsrfToken.php.
Cloudflare Workers
export default {
async fetch(request, env, ctx) {
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 });
}
const sigHeader = request.headers.get('Veridia-Signature') || '';
const rawBody = await request.text();
// 1. Verificar
if (!await verifySignature(sigHeader, rawBody, env.VERIDIA_WEBHOOK_SECRET)) {
return new Response('Invalid signature', { status: 401 });
}
const payload = JSON.parse(rawBody);
const eventId = payload.id;
if (!eventId) return new Response('ok', { status: 200 });
// 2. Idempotencia vía KV, con el id del evento como clave.
//
// KV es eventualmente consistente, así que esto es una protección de
// mejor esfuerzo, no un lock: dos reintentos que llegan con un segundo
// de diferencia pueden leer null los dos. Hacé que el efecto posterior
// también sea idempotente, o usá un Durable Object si procesar dos
// veces es inaceptable.
if (await env.WEBHOOK_LOG.get(eventId)) {
return new Response('ok', { status: 200 });
}
await env.WEBHOOK_LOG.put(eventId, JSON.stringify({ t: Date.now() }), {
expirationTtl: 86400 * 7, // 7 días
});
ctx.waitUntil(processVerification(payload, env));
return new Response('ok', { status: 200 });
},
};
async function processVerification(payload, env) {
switch (payload.type) {
case 'verification.approved':
return handleApproved(payload, env);
case 'verification.rejected':
return handleRejected(payload, env);
case 'verification.review_required':
return handleReviewRequired(payload, env);
default:
console.warn('Unknown Veridia event type', payload.type, payload.id);
}
}
async function verifySignature(header, rawBody, secret) {
const parts = {};
for (const piece of header.split(',')) {
const idx = piece.indexOf('=');
if (idx > 0) parts[piece.slice(0, idx).trim()] = piece.slice(idx + 1).trim();
}
const timestamp = parseInt(parts.t, 10);
const receivedSig = parts.v1;
if (!timestamp || !receivedSig) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > 300) return false;
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const sigBytes = await crypto.subtle.sign(
'HMAC',
key,
new TextEncoder().encode(`${timestamp}.${rawBody}`)
);
const expectedSig = Array.from(new Uint8Array(sigBytes))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
// Comparación en tiempo constante, con chequeo de longitud primero
if (expectedSig.length !== receivedSig.length) return false;
let diff = 0;
for (let i = 0; i < expectedSig.length; i++) {
diff |= expectedSig.charCodeAt(i) ^ receivedSig.charCodeAt(i);
}
return diff === 0;
}
El TTL de siete días en KV es cómodamente más largo que la ventana de reintentos de 12,6 minutos, así que cubre los duplicados ordinarios. No bloquea un evento posterior de la misma verificación, porque la clave es el id del evento — un revisor que aprueba un caso una semana después de que fuera a revisión produce un evt_* distinto y se procesa normalmente.
Observabilidad
Logueá esto en cada entrega:
| Campo | Por qué |
|---|---|
id | La identidad del evento. Lo que te va a pedir soporte. |
verificationId | Rastrear el caso a lo largo de tu sistema |
type | Filtrar y agrupar por resultado |
tenantId | Routing multi-tenant |
signature_valid | Detectar intentos de falsificación y secretos que no coinciden |
dedup_hit | Una tasa en aumento significa que estás respondiendo muy lento |
processing_duration_ms | Tu margen contra el timeout de 10 segundos |
error_class | Triage |
Una consulta útil, por ejemplo en Loki o Datadog:
rate(webhook_received{type="verification.review_required"}[5m])
Esa es tu cola de revisión manual llenándose en tiempo real. Mirala contra la capacidad de tus revisores — los casos en review quedan pendientes hasta que una persona actúe, y un backlog ahí es un backlog de usuarios bloqueados.
No loguees el cuerpo crudo por defecto. Contiene fieldsExtracted. Logueá la lista de campos de arriba y redactá el resto, o asumí que tu almacén de logs ahora guarda documentos de identidad.
Qué sigue
- Verificación de firma — el algoritmo en detalle
- Tipos de evento — contrato completo del payload
- Reintentos — garantías de entrega y recuperación
- Webhooks — volver al índice de la sección