CPFHub.io

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:

  1. Register your endpoint URL. https only and public addresses only.
  2. Copy the secret and store it in an environment variable (e.g. CPFHUB_WEBHOOK_SECRET).
  3. Use the test delivery to receive a kyc.test event and check the signature.

Event

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"
}
FieldTypeDescription
eventstringkyc.decision, or kyc.test for the test delivery
eventIdstringEvent ID
verificationIdstringVerification ID
linkIdstring | nullThe id returned by POST /kyc/links
externalIdstring | nullThe identifier you sent when creating the link
statusstringDecision: approved, manual_review or rejected. See Decision.
previousStatusstring | nullThe previous decision, when it changed (for example, from manual_review to approved)
decidedBystringautomatic (account rules) or manual (your team in the dashboard)
reasonsarrayReasons for the automatic decision: [{ "code", "message" }]. Empty for manual decisions.
occurredAtstringWhen the decision was made (ISO 8601, UTC)

Headers

HeaderDescription
x-cpfhub-eventThe same value as event
x-cpfhub-deliveryDelivery ID. Use it to ignore repeats.
x-cpfhub-signaturet=<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 2xx status 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-delivery you have already processed.
  • Order: there is no ordering guarantee between events. Use occurredAt and, when in doubt, confirm the current decision with GET /kyc/links/{id}.
  • Destination: https only 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