KYC

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

Identity. A subAccountId identifies the end-user across KYC, wallets, deposits and payouts. Create one with POST /api/subaccount before submitting KYC.

Overview

Brazilian regulation requires the person behind every on-ramp and off-ramp to be identified. Hodle uses a subaccount model: your API key is the main account, and each end-user you onboard is an API subaccount that carries its own KYC.

  • POST /api/subaccount — create a subaccount for one end-user. Returns a subAccountId. Pass accountType: "COMPANY" to onboard a business.
  • POST /api/kyc/document — register a document (selfie / ID) and get a one-time upload URL. Upload the image, then reference the returned documentId in POST /api/kyc.
  • POST /api/kyc — submit an individual subaccount's personal data + document ids, get back an attemptId.
  • POST /api/kyb — start business verification for a COMPANY subaccount, get back an attemptId plus hosted form URLs.
  • GET /api/kyc/{attemptId} — poll for the result of either a KYC or a KYB attempt (PENDINGAPPROVED or REJECTED).
  • Webhook kyc.completed — pushed when the attempt resolves. See Webhooks.

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

By default every new subaccount transacts only for its own taxId (the CPF its KYC was approved with). Moving funds for a third party — a beneficiary whose taxId differs from the subaccount's — is disabled until your company is explicitly enabled. See Third-party operations.

Subaccounts

A subaccount separates one end-user from your main account, so each carries its own KYC, beneficiaries, and operations. Subaccounts are permanent once created — deletion is not supported.

A subaccount is either an individual (INDIVIDUAL, the default) verified through POST /api/kyc, or a business (COMPANY) verified through POST /api/kyb. Pick the type at creation — it cannot be changed afterwards.

POST /api/subaccount

curl --request POST \
  --url https://api.hodle.com.br/api/subaccount \
  --header "Authorization: Bearer $API_KEY" \
  --header "Content-Type: application/json" \
  --data '{ "name": "Acme Ltda.", "accountType": "COMPANY" }'
FieldTypeRequiredDescription
namestringYesLabel to identify the subaccount. Up to 64 chars. For a business, use the legal company name.
accountTypestringNoINDIVIDUAL (default) or COMPANY. A COMPANY subaccount is verified via KYB, not KYC.
emailstringNoWhen provided, links a user record so the subaccount can be resolved for KYC/KYB.
201 Created
{ "success": true, "data": { "subAccountId": "c852df87-ac61-4259-8242-6451658dfedb" } }

GET /api/subaccount

List your subaccounts. Optional name (substring filter) and cursor (pagination) query params.

curl --url "https://api.hodle.com.br/api/subaccount" \
  --header "Authorization: Bearer $API_KEY"

GET /api/subaccount/{subAccountId}

Fetch a single subaccount by id.

curl --url "https://api.hodle.com.br/api/subaccount/c852df87-ac61-4259-8242-6451658dfedb" \
  --header "Authorization: Bearer $API_KEY"

Flow

  1. Collect the user's data and ID photos in your UI.
  2. POST /api/kyc/document — register the selfie and the identity document; for each, upload the image to the returned URL. See Uploading documents.
  3. POST /api/kyc — submit the personal data referencing the two documentIds. Hodle returns an attemptId.
  4. Wait for either the kyc.completed webhook or poll GET /api/kyc/{attemptId} every ~30 seconds.
  5. 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.

Uploading documents

KYC needs two images: a selfie and an identity document. You register each one, upload the bytes to a one-time URL, and then reference the returned documentId when you submit KYC. There are three steps per image.

1. Register the document — POST /api/kyc/document

curl --request POST \
  --url https://api.hodle.com.br/api/kyc/document \
  --header "Authorization: Bearer $API_KEY" \
  --header "Content-Type: application/json" \
  --data '{ "subAccountId": "c852df87-...", "documentType": "SELFIE" }'
FieldTypeRequiredDescription
subAccountIdstringYesThe subaccount the document belongs to.
documentTypestringYesSELFIE, SELFIE-FROM-LIVENESS, ID, DRIVERS-LICENSE, or PASSPORT.
isDoubleSidedbooleanNoSet true for documents with a back side (e.g. most national IDs). Returns a second URL.
201 Created
{
  "success": true,
  "data": {
    "documentId": "doc_b21...",
    "uploadUrlFront": "https://uploads.hodle.com.br/...",
    "uploadUrlBack": "https://uploads.hodle.com.br/..."
  }
}

uploadUrlBack is only present when isDoubleSided is true.

2. Upload the image

PUT the raw image bytes to each URL. The URL is one-time and expires shortly.

curl --request PUT \
  --url "$UPLOAD_URL_FRONT" \
  --header "If-None-Match: *" \
  --header "Content-Type: image/jpeg" \
  --data-binary "@selfie.jpg"

Accepted content types: image/jpeg, image/png, application/pdf.

3. Check readiness — GET /api/kyc/document/{documentId}

curl --url "https://api.hodle.com.br/api/kyc/document/doc_b21...?subAccountId=c852df87-..." \
  --header "Authorization: Bearer $API_KEY"
200 OK
{ "success": true, "data": { "documentId": "doc_b21...", "ready": true } }

Poll until ready: true (usually a few seconds), then submit KYC with the documentIds.

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 '{
    "subAccountId": "5b9f1a83b6b7c2b001f3c9e21",
    "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({
    subAccountId,
    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={
        "subAccountId": sub_account_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
subAccountIdstringYesThe subaccount's id, returned by POST /api/subaccount.
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.
uploadedSelfieIdstringYesdocumentId of the uploaded selfie, from POST /api/kyc/document.
uploadedDocumentIdstringYesdocumentId of the uploaded identity document.
sandboxRejectbooleanNoSandbox only. true simulates a rejected attempt; ignored in production.

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...",
    "subAccountId": "5b9f1a83b6b7c2b001f3c9e21",
    "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.

Sandbox onboarding

The sandbox runs the same endpoints with the same request/response shapes, so you can exercise the full onboarding (subaccount → document upload → KYC submit → APPROVED) end to end without real identity data. The only difference is the outcome is simulated: pass sandboxReject: true in POST /api/kyc to force a REJECTED attempt, or omit it (default) to get APPROVED. Ask support for sandbox credentials.

KYB (business accounts)

Businesses are onboarded as COMPANY subaccounts and verified with KYB (Know Your Business) instead of KYC. Unlike POST /api/kyc, you do not send the company's data in the request body — Hodle returns hosted form URLs where the company and its authorized representative complete the verification.

Flow

  1. POST /api/subaccount with accountType: "COMPANY" and an email — returns a subAccountId. The email is required so the subaccount can be resolved for KYB.
  2. POST /api/kyb with that subAccountId — returns an attemptId plus two form URLs.
  3. Redirect the company to basicCompanyDataUrl (company details + documents) and the signer to authorizedRepresentativeUrl (representative's identity).
  4. Wait for the kyc.completed webhook or poll GET /api/kyc/{attemptId} until status: APPROVED.

POST /api/kyb

Request

curl --request POST \
  --url https://api.hodle.com.br/api/kyb \
  --header "Authorization: Bearer $API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "subAccountId": "c852df87-ac61-4259-8242-6451658dfedb",
    "redirectUrl": "https://yourapp.com/kyb-complete"
  }'
const res = await fetch('https://api.hodle.com.br/api/kyb', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.HODLE_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    subAccountId,
    redirectUrl: 'https://yourapp.com/kyb-complete',
  }),
})
const data = await res.json()
import os, requests

res = requests.post(
    "https://api.hodle.com.br/api/kyb",
    headers={
        "Authorization": f"Bearer {os.environ['HODLE_API_KEY']}",
        "Content-Type": "application/json",
    },
    json={
        "subAccountId": sub_account_id,
        "redirectUrl": "https://yourapp.com/kyb-complete",
    },
)
data = res.json()

Parameters

FieldTypeRequiredDescription
subAccountIdstringYesThe COMPANY subaccount's id, returned by POST /api/subaccount.
redirectUrlstringNoWhere the user is sent after the hosted forms are submitted. Must be a valid URL.

Response

202 Accepted
{
  "success": true,
  "data": {
    "attemptId": "att_9b2c...",
    "status": "PENDING",
    "basicCompanyDataUrl": "https://kyb.hodle.com.br/company/...",
    "authorizedRepresentativeUrl": "https://kyb.hodle.com.br/representative/...",
    "createdAt": "2026-06-27T22:00:00.000Z"
  }
}
FieldTypeDescription
attemptIdstringPoll it with GET /api/kyc/{attemptId}.
basicCompanyDataUrlstringHosted form for the company's data and documents.
authorizedRepresentativeUrlstringHosted form for the authorized representative's identity.

Errors

404 — subaccount not found
{ "success": false, "error": "Subaccount not found for this platform" }

The result of a KYB attempt is polled and pushed exactly like a KYC attempt — see GET /api/kyc/{attemptId} and the webhook payload below.

Third-party operations

Every ramp operation moves funds for a taxholder. The operation endpoints — POST /api/deposit/asset, POST /api/withdraw/pix, and POST /api/wallet/payout — accept a taxId that identifies the beneficiary CPF behind the movement.

FieldTypeRequiredDescription
taxIdstringYesCPF of the beneficiary. Digits only. When omitted, Hodle assumes the subaccount's own taxId.
  • When taxId matches the subaccount's approved KYC taxId (or is omitted), the operation is a self operation and is always allowed.
  • When taxId differs from the subaccount's KYC taxId, it is a third-party operation.

New subaccounts have third-party operations disabled by default. A third-party operation on such a subaccount is rejected:

403 — third party not enabled
{ "success": false, "error": "Third party operations does not enabled to your company, call with support" }

To enable third-party operations for your company, contact support.

Webhook payload

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

{
  "event": "kyc.completed",
  "data": {
    "attemptId": "att_8f3a...",
    "subAccountId": "5b9f1a83b6b7c2b001f3c9e21",
    "status": "APPROVED"
  }
}

See Webhooks for signature verification.