Webhook da Verificação de Identidade
Cada decisão de uma Verificação de Identidade, automática ou feita pela sua equipe no painel, gera um POST para a URL que você cadastrou. O evento não traz dados pessoais: só IDs, a decisão e os motivos.
Configurar
No painel, em Verificações > Configurações > Integrações:
- Cadastre a URL do seu endpoint. Só
httpse só endereços públicos. - Copie o segredo e guarde numa variável de ambiente (ex.:
CPFHUB_WEBHOOK_SECRET). - Use o envio de teste para receber um evento
kyc.teste conferir a assinatura.
Evento
JSON
{
"event": "kyc.decision",
"eventId": "3e8a1f20-5c4b-4d9e-8f7a-6b2c1d0e9f8a",
"verificationId": "8f3c2a1e-77b0-4d3c-9a1f-0e5b6c7d8e9f",
"linkId": "5b1d7c2e-8a4f-4e7b-9c3d-2f6a1e0b9d47",
"externalId": "cliente-4821",
"status": "approved",
"previousStatus": null,
"decidedBy": "automatic",
"reasons": [],
"occurredAt": "2026-10-27T14:06:12.000Z"
}| Campo | Tipo | Descrição |
|---|---|---|
event | string | kyc.decision, ou kyc.test no envio de teste |
eventId | string | ID do evento |
verificationId | string | ID da verificação |
linkId | string | null | O id devolvido por POST /kyc/links |
externalId | string | null | O identificador que você enviou ao criar o link |
status | string | Decisão: approved, manual_review ou rejected. Veja Decisão. |
previousStatus | string | null | A decisão anterior, quando ela mudou (por exemplo, de manual_review para approved) |
decidedBy | string | automatic (regras da conta) ou manual (sua equipe no painel) |
reasons | array | Motivos da decisão automática: [{ "code", "message" }]. Vazio em decisões manuais. |
occurredAt | string | Quando a decisão foi tomada (ISO 8601, UTC) |
Cabeçalhos
| Header | Descrição |
|---|---|
x-cpfhub-event | O mesmo valor de event |
x-cpfhub-delivery | ID da entrega. Use para ignorar repetições. |
x-cpfhub-signature | t=<unix>,v1=<hex>: momento do envio em segundos e a assinatura |
Verificar a assinatura
v1 é o HMAC-SHA256, em hexadecimal, de "<t>.<corpo cru>" com o seu segredo. Calcule sobre o corpo exatamente como chegou, antes de qualquer parse de JSON, compare em tempo constante e recuse eventos com t a mais de 5 minutos do seu relógio.
import crypto from 'node:crypto'
import express from 'express'
const app = express()
// Corpo cru: a assinatura é calculada sobre os bytes recebidos
app.post('/webhooks/cpfhub', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('x-cpfhub-signature') ?? ''
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
const t = Number(parts.t)
const expected = crypto
.createHmac('sha256', process.env.CPFHUB_WEBHOOK_SECRET!)
.update(`${parts.t}.${req.body}`)
.digest()
const received = Buffer.from(parts.v1 ?? '', 'hex')
const valid =
received.length === expected.length &&
crypto.timingSafeEqual(received, expected) &&
Math.abs(Date.now() / 1000 - t) < 300
if (!valid) return res.sendStatus(401)
const event = JSON.parse(req.body.toString())
if (event.event === 'kyc.decision') {
// Ligue a decisão ao seu cadastro pelo externalId
console.log(event.externalId, event.status)
}
res.sendStatus(200)
})Entrega e retentativas
- Confirmação: responda com qualquer status
2xxem até 10 segundos. Processe o evento depois (fila), não antes de responder. - Retentativas: sem
2xx, por timeout ou redirecionamento, tentamos de novo em 1 min, 5 min, 30 min, 2 h e 12 h. Depois disso a entrega fica como falha, e você pode reenviar pelo painel. - Repetições: o mesmo evento pode chegar mais de uma vez. Ignore um
x-cpfhub-deliveryque você já processou. - Ordem: não há garantia de ordem entre eventos. Use
occurredAte, na dúvida, confirme a decisão atual comGET /kyc/links/{id}. - Destino: só
httpse só endereços públicos. Redirecionamentos não são seguidos.
⚠
Não confie no redirecionamento
A volta da pessoa para o seu redirectUrl não prova nada: ela pode abrir essa URL sozinha. Libere o cadastro só depois de um webhook com assinatura válida, ou da consulta pela API.
Atualizado em 27 de outubro de 2026