KYC

Submit and inspect KYC for end-users. Required before on-ramp and off-ramp.

Overview

Brazilian regulation requires the user behind every on-ramp and off-ramp to be identified. Hodle exposes a thin pass-through over Avenia's KYC level-1 attempt API:

  • POST /api/kyc — submit personal data + document photos, get back an attemptId.
  • GET /api/kyc/{attemptId} — poll for the result (PENDINGAPPROVED or REJECTED).
  • Webhook kyc.completed — pushed when the attempt resolves. See Webhooks.

A user is allowed to transact (/api/deposit/asset, /api/wallet/payout, /api/withdraw/pix) only after the most recent attempt is APPROVED.

Flow

  1. Collect the user's data and ID photos in your UI.
  2. POST /api/kyc — Hodle returns an attemptId.
  3. Wait for either the kyc.completed webhook or poll GET /api/kyc/{attemptId} every ~30 seconds.
  4. Once status: APPROVED, the user can on-ramp / off-ramp.

Typical resolution is under 2 minutes for clean submissions; manual review can take up to 24h.

POST /api/kyc

Request

curl --request POST \
  --url https://api.hodle.com.br/api/kyc \
  --header "Authorization: Bearer $API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "userId": "65f1a83b6b7c2b001f3c9e21",
    "fullName": "João da Silva",
    "dateOfBirth": "1990-01-15",
    "countryOfTaxId": "BRA",
    "taxIdNumber": "12345678900",
    "email": "joao@example.com",
    "phone": "+5511999990000",
    "country": "BRA",
    "state": "SP",
    "city": "São Paulo",
    "zipCode": "01000-000",
    "streetAddress": "Av. Paulista, 1000",
    "uploadedSelfieId": "sel_8f3...",
    "uploadedDocumentId": "doc_b21..."
  }'
const res = await fetch('https://api.hodle.com.br/api/kyc', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.HODLE_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    userId,
    fullName: 'João da Silva',
    dateOfBirth: '1990-01-15',
    countryOfTaxId: 'BRA',
    taxIdNumber: '12345678900',
    email: 'joao@example.com',
    country: 'BRA',
    state: 'SP',
    city: 'São Paulo',
    zipCode: '01000-000',
    streetAddress: 'Av. Paulista, 1000',
    uploadedSelfieId,
    uploadedDocumentId,
  }),
})
const data = await res.json()
import os, requests

res = requests.post(
    "https://api.hodle.com.br/api/kyc",
    headers={
        "Authorization": f"Bearer {os.environ['HODLE_API_KEY']}",
        "Content-Type": "application/json",
    },
    json={
        "userId": user_id,
        "fullName": "João da Silva",
        "dateOfBirth": "1990-01-15",
        "countryOfTaxId": "BRA",
        "taxIdNumber": "12345678900",
        "email": "joao@example.com",
        "country": "BRA",
        "state": "SP",
        "city": "São Paulo",
        "zipCode": "01000-000",
        "streetAddress": "Av. Paulista, 1000",
        "uploadedSelfieId": uploaded_selfie_id,
        "uploadedDocumentId": uploaded_document_id,
    },
)
data = res.json()

Parameters

FieldTypeRequiredDescription
userIdstringYesThe end-user's Hodle id, returned by POST /api/user/create.
fullNamestringYesFull legal name as it appears on the document.
dateOfBirthstringYesYYYY-MM-DD.
countryOfTaxIdstringYesISO-3 country code of the tax-id issuer (e.g. BRA).
taxIdNumberstringYesCPF (Brazilians) or equivalent. Digits only.
emailstringYesMust match the user's email on file.
phonestringNoE.164 (+5511...).
countrystringYesISO-3 country code of residence.
statestringYesState / federative unit.
citystringYesCity of residence.
zipCodestringYesPostal code.
streetAddressstringYesStreet + number + complement.
uploadedSelfieIdstringYesIdentifier returned by your upload-image endpoint (provided separately).
uploadedDocumentIdstringYesIdentifier of the front-of-document photo.

Response

202 Accepted
{
  "success": true,
  "data": {
    "attemptId": "att_8f3a...",
    "status": "PENDING",
    "createdAt": "2026-05-09T22:00:00.000Z"
  }
}

Errors

400 — validation
{
  "success": false,
  "error": "Validation failed",
  "details": [{ "field": "taxIdNumber", "message": "must contain only digits" }]
}
409 — user already approved
{ "success": false, "error": "User has an APPROVED KYC attempt" }

GET /api/kyc/{attemptId}

Request

curl --request GET \
  --url https://api.hodle.com.br/api/kyc/att_8f3a... \
  --header "Authorization: Bearer $API_KEY"
const res = await fetch(
  `https://api.hodle.com.br/api/kyc/${attemptId}`,
  { headers: { Authorization: `Bearer ${process.env.HODLE_API_KEY}` } },
)
const data = await res.json()
import os, requests

res = requests.get(
    f"https://api.hodle.com.br/api/kyc/{attempt_id}",
    headers={"Authorization": f"Bearer {os.environ['HODLE_API_KEY']}"},
)
data = res.json()

Response

status: APPROVED
{
  "success": true,
  "data": {
    "attemptId": "att_8f3a...",
    "userId": "65f1a83b6b7c2b001f3c9e21",
    "status": "APPROVED",
    "level": 1,
    "rejectionReason": null,
    "createdAt": "2026-05-09T22:00:00.000Z",
    "updatedAt": "2026-05-09T22:01:43.000Z"
  }
}
status: REJECTED
{
  "success": true,
  "data": {
    "attemptId": "att_8f3a...",
    "status": "REJECTED",
    "rejectionReason": "DOCUMENT_BLURRY",
    "createdAt": "2026-05-09T22:00:00.000Z",
    "updatedAt": "2026-05-09T22:01:43.000Z"
  }
}

Status values

StatusMeaning
PENDINGUnder review. Keep polling or wait for the webhook.
APPROVEDUser cleared. Transact endpoints will accept this user now.
REJECTEDFailed. rejectionReason is a stable enum your UI can map.
EXPIREDDocuments aged out. Submit a new attempt.

Webhook payload

When the attempt resolves, Hodle POSTs to your registered webhook with event: "kyc.completed":

{
  "event": "kyc.completed",
  "data": {
    "attemptId": "att_8f3a...",
    "userId": "65f1a83b6b7c2b001f3c9e21",
    "status": "APPROVED"
  }
}

See Webhooks for signature verification.