Identity Verification Webhook
Every decision of an Identity Verification, automatic or made by your team in the dashboard, triggers a POST to the URL you registered. The event contains no personal data: only IDs, the decision and the reasons.
Setup
In the dashboard, under Verificações > Configurações > Integrações:
- Register your endpoint URL.
httpsonly and public addresses only. - Copy the secret and store it in an environment variable (e.g.
CPFHUB_WEBHOOK_SECRET). - Use the test delivery to receive a
kyc.testevent and check the signature.
Event
{
"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"
}| Field | Type | Description |
|---|---|---|
event | string | kyc.decision, or kyc.test for the test delivery |
eventId | string | Event ID |
verificationId | string | Verification ID |
linkId | string | null | The id returned by POST /kyc/links |
externalId | string | null | The identifier you sent when creating the link |
status | string | Decision: approved, manual_review or rejected. See Decision. |
previousStatus | string | null | The previous decision, when it changed (for example, from manual_review to approved) |
decidedBy | string | automatic (account rules) or manual (your team in the dashboard) |
reasons | array | Reasons for the automatic decision: [{ "code", "message" }]. Empty for manual decisions. |
occurredAt | string | When the decision was made (ISO 8601, UTC) |
Headers
| Header | Description |
|---|---|
x-cpfhub-event | The same value as event |
x-cpfhub-delivery | Delivery ID. Use it to ignore repeats. |
x-cpfhub-signature | t=<unix>,v1=<hex>: send time in seconds and the signature |
Verify the signature
v1 is the HMAC-SHA256, in hexadecimal, of "<t>.<raw body>" with your secret. Compute it over the body exactly as received, before any JSON parsing, compare in constant time and reject events whose t is more than 5 minutes off your clock.
import crypto from 'node:crypto'
import express from 'express'
const app = express()
// Raw body: the signature is computed over the received bytes
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') {
// Tie the decision to your sign-up through the externalId
console.log(event.externalId, event.status)
}
res.sendStatus(200)
})Delivery and retries
- Acknowledgment: respond with any
2xxstatus within 10 seconds. Process the event afterwards (queue), not before responding. - Retries: without a
2xx, on timeout or redirect, we retry after 1 min, 5 min, 30 min, 2 h and 12 h. After that the delivery is marked as failed, and you can resend it from the dashboard. - Repeats: the same event may arrive more than once. Ignore an
x-cpfhub-deliveryyou have already processed. - Order: there is no ordering guarantee between events. Use
occurredAtand, when in doubt, confirm the current decision withGET /kyc/links/{id}. - Destination:
httpsonly and public addresses only. Redirects are not followed.
Do not trust the redirect
The person returning to your redirectUrl proves nothing: they can open that URL on their own. Approve the sign-up only after a webhook with a valid signature, or after a lookup through the API.
Updated on October 27, 2026