Pular para o conteúdo principal

Exemplos

Implementacoes completas e production-ready de handlers de webhook. Cada exemplo cobre o fluxo completo: verificacao de assinatura, idempotencia, processamento async, gerenciamento de erros, e observability.

Escolha o stack que corresponde ao seu backend.

Node.js / Express + Postgres

Setup tipico: servidor Express, Postgres para estado, BullMQ para trabalho 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 assinatura
if (!verifyVeridiaSignature(sigHeader, rawBody, VERIDIA_SECRET)) {
console.warn('Assinatura Veridia invalida', { eventType });
return res.status(401).send('Assinatura 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) {
// Ja processado — acknowledge e skip
return res.status(200).send('ok');
}

// 3. Persistir o 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 fazer trabalho pesado
res.status(200).send('ok');

// 5. Enfileirar processamento async (nao bloquear a resposta)
await verificationQueue.add(payload.event, payload, {
jobId: dedupKey,
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: processa verificacoes 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 escutando em :3000'));

Schema necessario:

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="Assinatura invalida")

payload = json.loads(raw_body)
dedup_key = f"{payload['verificationId']}:{payload['event']}"

existing = await db.execute(
WebhookLog.__table__.select().where(WebhookLog.dedup_key == dedup_key)
)
if existing.scalar_one_or_none():
return {"ok": True}

log_entry = WebhookLog(
dedup_key=dedup_key,
event_type=payload["event"],
verification_id=payload["verificationId"],
payload=payload,
)
db.add(log_entry)
await db.commit()

process_verification.delay(payload)

return {"ok": True}


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');

if (!$this->verifySignature($sigHeader, $rawBody, $secret)) {
Log::warning('Assinatura Veridia invalida');
return response()->json(['error' => 'Assinatura invalida'], 401);
}

$payload = json_decode($rawBody, true);
$dedupKey = $payload['verificationId'] . ':' . $payload['event'];

$exists = DB::table('webhook_log')
->where('dedup_key', $dedupKey)
->exists();

if ($exists) {
return response()->json(['ok' => true]);
}

DB::table('webhook_log')->insert([
'dedup_key' => $dedupKey,
'event_type' => $payload['event'],
'verification_id' => $payload['verificationId'],
'payload' => json_encode($payload),
'received_at' => now(),
]);

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 desconhecido', $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',
]);
}
}

Rotas (routes/api.php):

Route::post('/webhooks/veridia', [VeridiaWebhookController::class, 'handle']);

Importante: desative a protecao CSRF do Laravel para a rota do webhook. Adicione webhooks/* ao $except em app/Http/Middleware/VerifyCsrfToken.php.

Cloudflare Workers (serverless)

Para um setup totalmente serverless usando Cloudflare Workers + KV:

export default {
async fetch(request, env, ctx) {
if (request.method !== 'POST') {
return new Response('Metodo nao permitido', { status: 405 });
}

const sigHeader = request.headers.get('Veridia-Signature') || '';
const rawBody = await request.text();

if (!await verifySignature(sigHeader, rawBody, env.VERIDIA_WEBHOOK_SECRET)) {
return new Response('Assinatura invalida', { status: 401 });
}

const payload = JSON.parse(rawBody);
const dedupKey = `${payload.verificationId}:${payload.event}`;

const existing = await env.WEBHOOK_LOG.get(dedupKey);
if (existing) {
return new Response('ok', { status: 200 });
}

await env.WEBHOOK_LOG.put(dedupKey, JSON.stringify(payload), {
expirationTtl: 86400 * 7,
});

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('');

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 em producao, logue estes campos em cada webhook:

CampoPor que
verificationIdTrace atraves do seu sistema
eventFiltrar por tipo de evento
tenantIdRouting multi-tenant
signature_validDetectar tentativas nao autorizadas
dedup_hitDetectar loops de retry
processing_duration_msTracking de SLO
error_classTriage de falhas

Uma boa query Datadog / Honeycomb / Loki: webhook_received{event="verification.review_required"} | rate() mostra o volume da sua fila de revisao em tempo real.

Testing local

Para dev local, repita um webhook capturado com curl:

#!/bin/bash
SECRET="whsec_seu_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"

Proximos passos