Ejemplos
Implementaciones completas y production-ready de handlers de webhook. Cada ejemplo cubre el flow completo: verificacion de firma, idempotencia, procesamiento async, manejo de errores, y observability.
Elegi el stack que matchea tu backend.
Node.js / Express + Postgres
Setup tipico: servidor Express, Postgres para estado, BullMQ para trabajo async.
import express from 'express';
import crypto from 'node:crypto';
import { Pool } from 'pg';
import { Queue } from 'bullmq';
const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const verificationQueue = new Queue('verifications', {
connection: { host: process.env.REDIS_HOST },
});
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 eventType = req.header('Veridia-Event') || '';
const rawBody = req.body;
// 1. Verificar firma
if (!verifyVeridiaSignature(sigHeader, rawBody, VERIDIA_SECRET)) {
console.warn('Firma Veridia invalida', { eventType });
return res.status(401).send('Firma invalida');
}
const payload = JSON.parse(rawBody.toString('utf8'));
// 2. Check de idempotencia
const dedupKey = `${payload.verificationId}:${payload.event}`;
const exists = await pool.query(
'SELECT 1 FROM webhook_log WHERE dedup_key = $1',
[dedupKey]
);
if (exists.rows.length > 0) {
// Ya procesado — acknowledge y skip
return res.status(200).send('ok');
}
// 3. Persistir el evento (audit trail)
await pool.query(
`INSERT INTO webhook_log (dedup_key, event_type, verification_id, payload, received_at)
VALUES ($1, $2, $3, $4, NOW())`,
[dedupKey, payload.event, payload.verificationId, payload]
);
// 4. Acknowledge ANTES de hacer trabajo pesado
res.status(200).send('ok');
// 5. Encolar procesamiento async (no bloquear la respuesta)
await verificationQueue.add(payload.event, payload, {
jobId: dedupKey, // BullMQ tambien deduplica por jobId
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
});
}
);
function verifyVeridiaSignature(header, rawBody, secret) {
const parts = Object.fromEntries(
header.split(',').map(p => p.split('='))
);
const timestamp = parseInt(parts.t, 10);
const receivedSig = parts.v1;
if (!timestamp || !receivedSig) return false;
if (Math.abs(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');
return crypto.timingSafeEqual(
Buffer.from(expectedSig, 'hex'),
Buffer.from(receivedSig, 'hex')
);
}
// Worker: procesa verificaciones async
const worker = new Worker('verifications', async (job) => {
const payload = job.data;
switch (payload.event) {
case 'verification.approved':
await handleApproved(payload);
break;
case 'verification.rejected':
await handleRejected(payload);
break;
case 'verification.review_required':
await handleReviewRequired(payload);
break;
}
}, { connection: { host: process.env.REDIS_HOST } });
async function handleApproved(payload) {
await pool.query(
`UPDATE users SET kyc_status = 'verified', kyc_completed_at = $1
WHERE user_ref = $2`,
[payload.completedAt, 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 sendRejectionEmail(payload.userRef);
}
async function handleReviewRequired(payload) {
await pool.query(
`UPDATE users SET kyc_status = 'pending_review'
WHERE user_ref = $1`,
[payload.userRef]
);
await notifyReviewers(payload);
}
app.listen(3000, () => console.log('Servidor de webhooks escuchando en :3000'));
Schema requerido:
CREATE TABLE webhook_log (
dedup_key VARCHAR(255) PRIMARY KEY,
event_type VARCHAR(64) NOT NULL,
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);
Python / FastAPI + SQLAlchemy + Celery
import hmac
import hashlib
import time
import os
import json
from fastapi import FastAPI, Request, HTTPException, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from .database import get_db
from .models import WebhookLog, User
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="Firma invalida")
payload = json.loads(raw_body)
dedup_key = f"{payload['verificationId']}:{payload['event']}"
# Check de idempotencia
existing = await db.execute(
WebhookLog.__table__.select().where(WebhookLog.dedup_key == dedup_key)
)
if existing.scalar_one_or_none():
return {"ok": True}
# Persistir
log_entry = WebhookLog(
dedup_key=dedup_key,
event_type=payload["event"],
verification_id=payload["verificationId"],
payload=payload,
)
db.add(log_entry)
await db.commit()
# Encolar procesamiento async via Celery
process_verification.delay(payload)
return {"ok": True}
# Tarea Celery (en tasks.py)
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:
if payload["event"] == "verification.approved":
handle_approved(payload)
elif payload["event"] == "verification.rejected":
handle_rejected(payload)
elif payload["event"] == "verification.review_required":
handle_review_required(payload)
except Exception as exc:
raise self.retry(exc=exc)
def handle_approved(payload: dict):
update_user_kyc(payload["userRef"], "verified", payload["completedAt"])
send_welcome_email(payload["userRef"])
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 firma
if (!$this->verifySignature($sigHeader, $rawBody, $secret)) {
Log::warning('Firma Veridia invalida');
return response()->json(['error' => 'Firma invalida'], 401);
}
$payload = json_decode($rawBody, true);
$dedupKey = $payload['verificationId'] . ':' . $payload['event'];
// 2. Check de idempotencia
$exists = DB::table('webhook_log')
->where('dedup_key', $dedupKey)
->exists();
if ($exists) {
return response()->json(['ok' => true]);
}
// 3. Persistir
DB::table('webhook_log')->insert([
'dedup_key' => $dedupKey,
'event_type' => $payload['event'],
'verification_id' => $payload['verificationId'],
'payload' => json_encode($payload),
'received_at' => now(),
]);
// 4. Encolar async (Laravel jobs)
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[$kv[0]] = $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);
}
}
// 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 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['event']) {
'verification.approved' => $this->handleApproved(),
'verification.rejected' => $this->handleRejected(),
'verification.review_required' => $this->handleReviewRequired(),
default => Log::warning('Evento Veridia desconocido', $this->payload),
};
}
private function handleApproved(): void
{
User::where('user_ref', $this->payload['userRef'])->update([
'kyc_status' => 'verified',
'kyc_completed_at' => $this->payload['completedAt'],
]);
}
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',
]);
}
}
Rutas (routes/api.php):
Route::post('/webhooks/veridia', [VeridiaWebhookController::class, 'handle']);
Importante: desactiva la proteccion CSRF de Laravel para la ruta del webhook. Agrega webhooks/* a $except en app/Http/Middleware/VerifyCsrfToken.php.
Cloudflare Workers (serverless)
Para un setup totalmente serverless usando Cloudflare Workers + KV:
export default {
async fetch(request, env, ctx) {
if (request.method !== 'POST') {
return new Response('Metodo no permitido', { status: 405 });
}
const sigHeader = request.headers.get('Veridia-Signature') || '';
const rawBody = await request.text();
// 1. Verificar firma
if (!await verifySignature(sigHeader, rawBody, env.VERIDIA_WEBHOOK_SECRET)) {
return new Response('Firma invalida', { status: 401 });
}
const payload = JSON.parse(rawBody);
const dedupKey = `${payload.verificationId}:${payload.event}`;
// 2. Idempotencia via KV
const existing = await env.WEBHOOK_LOG.get(dedupKey);
if (existing) {
return new Response('ok', { status: 200 });
}
// 3. Marcar como recibido
await env.WEBHOOK_LOG.put(dedupKey, JSON.stringify(payload), {
expirationTtl: 86400 * 7, // 7 dias
});
// 4. Procesar async via Queue (o inline si es rapido)
ctx.waitUntil(processVerification(payload, env));
return new Response('ok', { status: 200 });
},
};
async function verifySignature(header, rawBody, secret) {
const parts = Object.fromEntries(
header.split(',').map(p => p.split('='))
);
const timestamp = parseInt(parts.t, 10);
const receivedSig = parts.v1;
if (!timestamp || !receivedSig) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const signedPayload = `${timestamp}.${rawBody}`;
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(signedPayload)
);
const expectedSig = Array.from(new Uint8Array(sigBytes))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
// Comparacion constant-time
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;
}
Checklist de observability
Para deployments en produccion, loguea estos campos en cada webhook:
| Campo | Por que |
|---|---|
verificationId | Trazearlo a traves de tu sistema |
event | Filtrar por tipo de evento |
tenantId | Routing multi-tenant |
signature_valid | Detectar intentos no autorizados |
dedup_hit | Detectar loops de retry |
processing_duration_ms | Tracking de SLO |
error_class | Triage de fallas |
Una buena query Datadog / Honeycomb / Loki: webhook_received{event="verification.review_required"} | rate() te muestra el volumen de tu cola de revision en tiempo real.
Testing local
Para dev local, repeti un webhook capturado con curl:
#!/bin/bash
# captura un webhook real desde tu dashboard "Webhooks > Deliveries",
# despues guarda el body raw y el header de firma
SECRET="whsec_tu_test_secret"
BODY=$(cat captured-body.json)
TS=$(date +%s)
SIG=$(printf "%s.%s" "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -X POST http://localhost:3000/webhooks/veridia \
-H "Content-Type: application/json" \
-H "Veridia-Signature: t=${TS},v1=${SIG}" \
-H "Veridia-Event: verification.approved" \
-d "$BODY"
Que sigue
- Webhooks overview — volver al overview
- Verificacion de firma — referencia del algoritmo
- Tipos de eventos — schemas de payload