Exemplos
Implementações completas de handlers: verificação de assinatura, idempotência, processamento assíncrono e observabilidade. Escolha a que corresponde à sua stack.
As quatro seguem a mesma estrutura, e vale nomear as partes antes de ler o código:
- Verifique a assinatura sobre os bytes brutos. Rejeite com
401se falhar. - Deduplique por
payload.id. A entrega é ao menos uma vez. - Persista, depois confirme. A linha que você grava é a confirmação; o trabalho acontece depois.
- Roteie por
payload.type. Não porevent.
Estes exemplos chaveiam registros de usuário por userRef, que é o valor que você passou para /v1/verify/init. Ele é null se você não passou nenhum — nesse caso, armazene você mesmo o mapeamento verificationId → usuário no momento do init e consulte-o aqui. Não há outro campo de correlação: o metadata enviado para /submit não é devolvido no 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. Verifique a assinatura sobre os bytes brutos
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. Idempotência. `id` é o valor evt_*: estável nas seis tentativas de
// reentrega de um evento, distinto entre eventos. Uma chave construída
// a partir de verificationId colapsaria o evento review_required da
// máquina e a aprovação posterior do revisor em um só, e a aprovação —
// que chega em segundo — seria a descartada.
const eventId = payload.id;
// 3. Persista. O ON CONFLICT faz do próprio insert a checagem de
// deduplicação, de modo que duas reentregas concorrentes não podem
// ambas passar por um 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'); // já visto
}
// 4. Entregue o trabalho ao armazenamento durável ANTES de confirmar.
// A ordem importa e a ordem tentadora é a errada. Responder 200 primeiro
// parece mais rápido, mas se o processo morrer nesse intervalo a linha
// de deduplicação já foi commitada e a Veridia já ouviu "recebido" —
// então ela nunca reenvia, e aquele veredito se perde sem registro.
// `jobId: eventId` torna o próprio enfileiramento idempotente, então uma
// retentativa que chegue duas vezes aqui ainda produz um único job.
await verificationQueue.add(payload.type, payload, {
jobId: eventId,
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
});
// 5. Só agora confirme. O trabalho pesado fica no worker, não aqui:
// o timeout de entrega da Veridia é 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');
// Checagem de comprimento primeiro: timingSafeEqual lança RangeError com
// comprimentos diferentes, transformando uma assinatura forjada curta em
// um 500 em vez de um 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: processa as verificações de forma assí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:
// Registre no log e retorne sucesso. Lançar exceção aqui reprocessaria
// um job que nunca poderá passar.
console.warn('Unknown Veridia event type', {
type: payload.type,
eventId: payload.id,
});
}
}, { connection });
async function handleApproved(payload) {
// createdAt é em SEGUNDOS unix. Não existe completedAt no 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 exponha as 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,
// Os níveis são ok / warn / err. Não existe 'critical'.
priority: payload.flags.some(f => f.level === 'err') ? 'high' : 'normal',
});
}
app.listen(3000, () => console.log('Webhook server listening on :3000'));
Schema:
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 é a chave primária, e verification_id é apenas indexado. Uma verificação legitimamente produz vários eventos — um review_required seguido do approved do revisor —, então tornar verification_id único rejeitaria justamente a decisão que importa.
A coluna payload agora contém fieldsExtracted: nome completo, número do documento, data de nascimento. Qualquer política de retenção e de acesso que cubra dados pessoais na sua empresa cobre esta tabela.
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(), e não payload["..."]: um KeyError aqui vira um 500, e o
# despachante o reentregaria seis vezes antes de estacionar o evento
# como falho.
event_id = payload.get("id")
event_type = payload.get("type")
if not event_id or not event_type:
# Malformado para nós, mas não é algo que uma reentrega resolva.
# 2xx e registre no log.
return {"ok": True, "ignored": "missing id or type"}
# Idempotência: deixe a restrição de unicidade decidir, não um SELECT prévio.
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 é em segundos unix; não 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"] pode ser nulo — comparar None com um número levanta
# TypeError em Python, o que faria a task falhar em vez da checagem.
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,
)
Repare na diferença em relação ao JavaScript: payload["event"] com uma chave inexistente levanta KeyError, então um handler escrito com o nome de campo errado retorna 500, o evento é reentregue seis vezes e depois estacionado como falho. Barulhento em vez de silencioso — mas o veredicto continua perdido até que alguém o recoloque na fila.
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. Verifique
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) {
// Não é reentregável com sucesso. Confirme para não queimar a
// janela de reentregas.
Log::warning('Veridia webhook missing id or type');
return response()->json(['ok' => true]);
}
// 2. Idempotência: insertOrIgnore contra a chave primária, de modo que
// duas reentregas concorrentes não podem ambas passar da checagem.
$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]); // duplicata
}
// 3. Enfileire
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 é em segundos unix. Não 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'
);
}
}
Rota (routes/api.php):
Route::post('/webhooks/veridia', [VeridiaWebhookController::class, 'handle']);
Importante: exclua a rota do webhook da proteção CSRF. Adicione webhooks/* a $except em 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. Verifique
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. Idempotência via KV, chaveada pelo id do evento.
//
// O KV tem consistência eventual, então isto é uma proteção de melhor
// esforço, não um lock: duas reentregas que chegam com menos de um
// segundo de diferença podem ambas ler null. Torne o efeito seguinte
// idempotente também, ou use um Durable Object se processar duas vezes
// for inaceitável.
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 dias
});
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('');
// Comparação em tempo constante, com checagem de comprimento primeiro
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;
}
O TTL de sete dias no KV é confortavelmente maior que a janela de reentrega de 12,6 minutos, então ele cobre as duplicatas comuns. Ele não bloqueia um evento posterior da mesma verificação, porque a chave é o id do evento — um revisor aprovando um caso uma semana depois de ele ter ido para revisão produz um evt_* diferente e é processado normalmente.
Observabilidade
Registre estes campos em cada entrega:
| Campo | Por quê |
|---|---|
id | A identidade do evento. O que o suporte vai pedir. |
verificationId | Rastrear o caso pelo seu sistema |
type | Filtrar e agrupar por resultado |
tenantId | Roteamento multi-tenant |
signature_valid | Detectar tentativas de forjar assinaturas e segredos divergentes |
dedup_hit | Uma taxa crescente significa que você está respondendo devagar demais |
processing_duration_ms | Sua margem contra o timeout de 10 segundos |
error_class | Triagem |
Uma consulta útil, por exemplo no Loki ou no Datadog:
rate(webhook_received{type="verification.review_required"}[5m])
Essa é a sua fila de revisão manual enchendo em tempo real. Acompanhe-a contra a vazão dos seus revisores — casos em review ficam pendentes até que uma pessoa aja, e um acúmulo ali é um acúmulo de usuários bloqueados.
Não logue o corpo bruto por padrão. Ele contém fieldsExtracted. Logue a lista de campos acima e omita o resto, ou aceite que seu armazenamento de logs agora guarda documentos de identidade.
O que vem a seguir
- Verificação de assinatura — o algoritmo em detalhe
- Tipos de evento — contrato completo do payload
- Reentregas — garantias de entrega e recuperação
- Webhooks — de volta ao índice da seção