KYC
Submit and inspect KYC for end-users. Required before on-ramp and off-ramp.
Identity. A
subAccountIdidentifies the end-user across KYC, wallets, deposits and payouts. Create one withPOST /api/subaccountbefore 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 asubAccountId. PassaccountType: "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 returneddocumentIdinPOST /api/kyc.POST /api/kyc— submit an individual subaccount's personal data + document ids, get back anattemptId.POST /api/kyb— start business verification for aCOMPANYsubaccount, get back anattemptIdplus hosted form URLs.GET /api/kyc/{attemptId}— poll for the result of either a KYC or a KYB attempt (PENDING→APPROVEDorREJECTED).- 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" }'| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Label to identify the subaccount. Up to 64 chars. For a business, use the legal company name. |
accountType | string | No | INDIVIDUAL (default) or COMPANY. A COMPANY subaccount is verified via KYB, not KYC. |
email | string | No | When provided, links a user record so the subaccount can be resolved for KYC/KYB. |
{ "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
- Collect the user's data and ID photos in your UI.
POST /api/kyc/document— register the selfie and the identity document; for each, upload the image to the returned URL. See Uploading documents.POST /api/kyc— submit the personal data referencing the twodocumentIds. Hodle returns anattemptId.- Wait for either the
kyc.completedwebhook or pollGET /api/kyc/{attemptId}every ~30 seconds. - 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" }'| Field | Type | Required | Description |
|---|---|---|---|
subAccountId | string | Yes | The subaccount the document belongs to. |
documentType | string | Yes | SELFIE, SELFIE-FROM-LIVENESS, ID, DRIVERS-LICENSE, or PASSPORT. |
isDoubleSided | boolean | No | Set true for documents with a back side (e.g. most national IDs). Returns a second URL. |
{
"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"{ "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
| Field | Type | Required | Description |
|---|---|---|---|
subAccountId | string | Yes | The subaccount's id, returned by POST /api/subaccount. |
fullName | string | Yes | Full legal name as it appears on the document. |
dateOfBirth | string | Yes | YYYY-MM-DD. |
countryOfTaxId | string | Yes | ISO-3 country code of the tax-id issuer (e.g. BRA). |
taxIdNumber | string | Yes | CPF (Brazilians) or equivalent. Digits only. |
email | string | Yes | Must match the user's email on file. |
phone | string | No | E.164 (+5511...). |
country | string | Yes | ISO-3 country code of residence. |
state | string | Yes | State / federative unit. |
city | string | Yes | City of residence. |
zipCode | string | Yes | Postal code. |
streetAddress | string | Yes | Street + number + complement. |
uploadedSelfieId | string | Yes | documentId of the uploaded selfie, from POST /api/kyc/document. |
uploadedDocumentId | string | Yes | documentId of the uploaded identity document. |
sandboxReject | boolean | No | Sandbox only. true simulates a rejected attempt; ignored in production. |
Response
{
"success": true,
"data": {
"attemptId": "att_8f3a...",
"status": "PENDING",
"createdAt": "2026-05-09T22:00:00.000Z"
}
}Errors
{
"success": false,
"error": "Validation failed",
"details": [{ "field": "taxIdNumber", "message": "must contain only digits" }]
}{ "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
{
"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"
}
}{
"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
| Status | Meaning |
|---|---|
PENDING | Under review. Keep polling or wait for the webhook. |
APPROVED | User cleared. Transact endpoints will accept this user now. |
REJECTED | Failed. rejectionReason is a stable enum your UI can map. |
EXPIRED | Documents 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
POST /api/subaccountwithaccountType: "COMPANY"and anemail— returns asubAccountId. Theemailis required so the subaccount can be resolved for KYB.POST /api/kybwith thatsubAccountId— returns anattemptIdplus two form URLs.- Redirect the company to
basicCompanyDataUrl(company details + documents) and the signer toauthorizedRepresentativeUrl(representative's identity). - Wait for the
kyc.completedwebhook or pollGET /api/kyc/{attemptId}untilstatus: 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
| Field | Type | Required | Description |
|---|---|---|---|
subAccountId | string | Yes | The COMPANY subaccount's id, returned by POST /api/subaccount. |
redirectUrl | string | No | Where the user is sent after the hosted forms are submitted. Must be a valid URL. |
Response
{
"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"
}
}| Field | Type | Description |
|---|---|---|
attemptId | string | Poll it with GET /api/kyc/{attemptId}. |
basicCompanyDataUrl | string | Hosted form for the company's data and documents. |
authorizedRepresentativeUrl | string | Hosted form for the authorized representative's identity. |
Errors
{ "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.
| Field | Type | Required | Description |
|---|---|---|---|
taxId | string | Yes | CPF of the beneficiary. Digits only. When omitted, Hodle assumes the subaccount's own taxId. |
- When
taxIdmatches the subaccount's approved KYC taxId (or is omitted), the operation is a self operation and is always allowed. - When
taxIddiffers 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:
{ "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.