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 and get a one-time upload URL, or — for the selfie — a hosted liveness session. Reference the returneddocumentIdinPOST /api/kyc.POST /api/kyc— submit an individual subaccount's personal data + document ids, get back anattemptId.POST /api/kyc/import-token— reuse a Sumsub verification you already ran yourself: post a share token instead of documents, 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) 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 the ID photo in your UI.
POST /api/kyc/documentwithdocumentType: "ID"(orDRIVERS-LICENSE/PASSPORT) — upload the image to the returned URL. See Uploading documents.POST /api/kyc/documentwithdocumentType: "SELFIE-FROM-LIVENESS"— send the user to the returnedlivenessUrlto record the selfie. See Liveness selfies.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.
Already verified this user on your own Sumsub account? Skip steps 1–4 and import that
verification with POST /api/kyc/import-token — you still wait
for APPROVED at step 5.
The selfie for an individual subaccount must come from a liveness session
(SELFIE-FROM-LIVENESS). A plain SELFIE upload is accepted by
POST /api/kyc/document and will even report ready: true, but POST /api/kyc
rejects it with InvalidFieldError: uploadedSelfieId is invalid.
Uploading documents
KYC needs two images: a selfie and an identity document. The identity document is
uploaded by you to a one-time URL; the selfie is recorded by the user in a hosted liveness
session (see Liveness selfies). Either way you end up with a
documentId to reference when you submit KYC.
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-FROM-LIVENESS" }'| Field | Type | Required | Description |
|---|---|---|---|
subAccountId | string | Yes | The subaccount the document belongs to. |
documentType | string | Yes | SELFIE-FROM-LIVENESS for the selfie; ID, DRIVERS-LICENSE or PASSPORT for the identity document. SELFIE exists but is not accepted by POST /api/kyc — see the warning above. |
isDoubleSided | boolean | No | Set true for documents with a back side (e.g. most national IDs). Returns a second URL. |
The response shape depends on the documentType.
{
"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.
Liveness selfies
When documentType is SELFIE-FROM-LIVENESS there is no upload URL — the user records
the selfie in a hosted liveness session instead. The response carries the session instead of
the upload URLs, and steps 2 and 3 below do not apply to it.
{
"success": true,
"data": {
"documentId": "doc_b21...",
"sessionId": "0ee149a1-...",
"livenessUrl": "https://app.avenia.io/liveness/0ee149a1-...?jwt=...",
"validateLivenessToken": "eyJhbGciOi..."
}
}Then:
- Send the user to
livenessUrl. Use a full-page redirect,window.open, or a deep link on mobile — see the framing note below. The page needs a camera, so it has to run on a device that has one. - Poll
GET /api/kyc/document/{documentId}untilready: true. Before the capture it reportsready: falsewithuploadStatusFront: "WAITING-UPLOAD". - Use that
documentIdasuploadedSelfieIdinPOST /api/kyc.
The liveness page cannot be embedded in an iframe. It is served with
X-Frame-Options: DENY and Content-Security-Policy: frame-ancestors 'none', so the
browser refuses to render it inside any parent page — standard clickjacking protection for
a biometric capture screen. No allow attribute or CSP change on your side overrides it.
Navigate to the URL instead of framing it.
livenessUrl carries a JWT that expires 5 minutes after it is issued, so create the
document right before you hand the link over rather than ahead of time. If it expires,
register a new SELFIE-FROM-LIVENESS document and start over — the old documentId stays
unusable. There is no redirectUrl on this endpoint (that parameter belongs to the hosted
Web SDK flow), so bring the user back to your own page and poll for readiness.
The capture page is in pt-BR and on our domain
livenessUrl points at https://app.hodle.com.br/liveness/{documentId}, which renders the
same biometric session in pt-BR — including the camera instructions. The provider's own page
is returned alongside it as aveniaLivenessUrl, so you can fall back to it at any time.
{
"success": true,
"data": {
"documentId": "doc_b21...",
"sessionId": "0ee149a1-...",
"livenessUrl": "https://app.hodle.com.br/liveness/doc_b21...#sessionId=0ee149a1-...&token=eyJhbGciOi...",
"aveniaLivenessUrl": "https://app.avenia.io/liveness/0ee149a1-...?jwt=...",
"validateLivenessToken": "eyJhbGciOi..."
}
}The session travels in the URL fragment, never in the query string, so the short-lived
token stays out of server logs, CDN caches and Referer headers. Hand the URL over whole and
do not rewrite it.
The selfie never passes through Hodle. The video streams from the user's browser straight to AWS Rekognition inside the KYC provider's own AWS account. We host the page and relay one short-lived token — we do not receive, store or forward the image.
Hosting the capture yourself
If you render
AWS Amplify's FaceLivenessDetector
in your own app instead of using either page, confirm the capture when onAnalysisComplete
fires:
curl --request POST \
--url https://api.hodle.com.br/api/public/liveness/validate \
--header "Content-Type: application/json" \
--data '{ "validateLivenessToken": "eyJhbGciOi..." }'The token is the only credential — no API key — because the call comes from the end user's
browser. It is rate limited per IP, and it is what turns the document into a valid liveness
proof: until it succeeds, GET /api/kyc/document/{documentId} keeps reporting
ready: false and POST /api/kyc rejects the uploadedSelfieId.
Calling the provider's validation endpoint directly from a browser does not work — it answers
the CORS preflight without an Access-Control-Allow-Origin header.
2. Upload the image
This step applies to the identity document only — liveness selfies have no upload URL.
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 "@document-front.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,
"documentType": "SELFIE",
"uploadStatusFront": "COMPLETED"
}
}| Field | Description |
|---|---|
ready | true when the documentId can be used in POST /api/kyc. |
uploadStatusFront | PENDING, PROCESSING, COMPLETED or EXPIRED. Omitted when the provider is silent. |
uploadErrorFront | Present only when the upload failed. Same pair exists for ...Back. |
Poll until ready: true (usually a few seconds), then submit KYC with the documentIds.
If POST /api/kyc answers InvalidFieldError: uploadedSelfieId is invalid while the
document reports ready: true, check the documentType first: a plain SELFIE always
fails there, however healthy it looks. If it already is a SELFIE-FROM-LIVENESS, the
capture itself was rejected — start a new liveness session.
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": "[email protected]",
"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: '[email protected]',
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": "[email protected]",
"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 that issued the tax id (e.g. BRA, USA, PRT). Set this to the issuing country for a non-Brazilian — it does not have to be BRA, and it is independent of country (residence). |
taxIdNumber | string | Yes | The tax id issued by countryOfTaxId — CPF for BRA, the national equivalent otherwise. Digits only: strip dots, dashes, and letters before sending, or the call returns 400 must contain only digits. |
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 | Federative unit as an ISO 3166-2 subdivision code without the country prefix — SP, not BR-SP. A prefixed value fails with InvalidFieldError: state is invalid. |
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 drives the attempt to REJECTED; ignored in production. See Sandbox onboarding. |
uploadedSelfieId and uploadedDocumentId are always required on this endpoint — there is no way to skip them here. If you already verified the user on your own Sumsub account, do not submit the form: import that verification with POST /api/kyc/import-token instead.
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" }POST /api/kyc/import-token
Reuse a KYC you already ran, instead of verifying the same person twice.
If you verify your users on your own Sumsub account, mint a single-use share token for
an applicant with a GREEN review status and post it here. Hodle hands it to the provider,
which reads the applicant back and opens a KYC attempt from it — no documents, no liveness,
no personal-data form. From there the attempt behaves exactly like any other: it resolves to
APPROVED or REJECTED and reaches you through
GET /api/kyc/{attemptId} or the kyc.completed webhook.
This endpoint is off by default. Hodle has to enable Sumsub import for your account,
and your Sumsub share tokens must be minted for Hodle's provider as the recipient
(forClientId) — a generic token is refused. Ask Hodle for the recipient client id before
you integrate. Individual (INDIVIDUAL) subaccounts only; a COMPANY subaccount is
verified through POST /api/kyb.
Request
curl --request POST \
--url https://api.hodle.com.br/api/kyc/import-token \
--header "Authorization: Bearer $API_KEY" \
--header "Content-Type: application/json" \
--data '{
"subAccountId": "5b9f1a83b6b7c2b001f3c9e21",
"importToken": "_act-sbx-jwt-eyJhbGciOi..."
}'const res = await fetch('https://api.hodle.com.br/api/kyc/import-token', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.HODLE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
subAccountId: '5b9f1a83b6b7c2b001f3c9e21',
importToken: shareToken,
}),
})
const data = await res.json()import os, requests
res = requests.post(
"https://api.hodle.com.br/api/kyc/import-token",
headers={"Authorization": f"Bearer {os.environ['HODLE_API_KEY']}"},
json={
"subAccountId": "5b9f1a83b6b7c2b001f3c9e21",
"importToken": share_token,
},
)
data = res.json()Parameters
| Field | Type | Required | Description |
|---|---|---|---|
subAccountId | string | No | The subaccount to import the verification into. Omit to import into the API-key account itself. Must be an INDIVIDUAL subaccount you created. |
importToken | string | Yes | The Sumsub share token. Single-use, and importable once per user. Surrounding whitespace is trimmed; an empty or whitespace-only value is rejected without any provider call. |
Hodle never stores or logs the token — it exists only for the duration of the provider call.
Persist the returned attemptId, not the token.
Response
{
"success": true,
"data": {
"attemptId": "att_8f3a...",
"source": "SUMSUB_IMPORT",
"status": "PENDING",
"createdAt": "2026-05-09T22:00:00.000Z"
}
}An imported attempt polls and resolves through the same endpoints as any other, with one
difference: level reads sumsub-token-<client-id> instead of a Hodle level, in both
GET /api/kyc/{attemptId} and the kyc.completed webhook. Treat it
as an opaque label — status is what gates the user.
A 202 means accepted for processing, not approved. The applicant behind the token is
validated asynchronously, so an imported attempt can still land on REJECTED with the same
rejection labels a normal submission uses. Never let a user
transact off the 202 alone — wait for APPROVED.
Errors
Every refusal carries a stable errorCode to branch on. A wrong token is rejected — it never
opens an attempt.
errorCode | Status | Meaning | What to do |
|---|---|---|---|
INVALID_IMPORT_TOKEN | 400 | Token missing, empty, malformed, expired, or rejected by Sumsub. | Mint a fresh token for a GREEN applicant. |
IMPORT_TOKEN_ALREADY_USED | 409 | That token was already consumed, or this user already imported one. | Mint a new token; if the user already imported, poll the existing attempt. |
IMPORT_IN_PROGRESS | 409 | A previous import for this subaccount is still running. | Poll GET /api/kyc/{attemptId} until it resolves. |
IMPORT_NOT_ENABLED | 502 | Sumsub import is not enabled for this account. | Contact Hodle to enable it. |
IMPORT_FAILED | 400 | The provider refused the import for another reason (error carries the text). | Read error; retry only if it describes something transient. |
{
"success": false,
"error": "Validation failed",
"errorCode": "INVALID_IMPORT_TOKEN",
"details": [{ "field": "importToken", "message": "importToken is required" }]
}{
"success": false,
"error": "The share token is invalid or was rejected by Sumsub. Mint a new one for a GREEN applicant.",
"errorCode": "INVALID_IMPORT_TOKEN"
}{
"success": false,
"error": "This share token was already consumed. Mint a new one and try again.",
"errorCode": "IMPORT_TOKEN_ALREADY_USED"
}A 404 Subaccount not found for this platform means the subAccountId is not one of yours —
the same answer a foreign id gets everywhere else in the API, so the status cannot be used to
probe whether it exists.
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,
"rejectionLabels": [],
"createdAt": "2026-05-09T22:00:00.000Z",
"updatedAt": "2026-05-09T22:01:43.000Z"
}
}{
"success": true,
"data": {
"attemptId": "att_8f3a...",
"status": "REJECTED",
"rejectionReason": "birthdate does not match",
"rejectionLabels": ["BIRTHDATE_MISMATCH"],
"createdAt": "2026-05-09T22:00:00.000Z",
"updatedAt": "2026-05-09T22:01:43.000Z"
}
}rejectionReason is a human-readable sentence; rejectionLabels is the machine-readable
list your UI should branch on. A single attempt may contain more than one rejection label.
KYC rejection reasons
The following labels can be returned in rejectionLabels for a rejected KYC Level 1 attempt.
Keep the label unchanged in your integration and use the description for the user-facing
message or remediation guidance.
| Label | Meaning |
|---|---|
OBIT_INDICATION | The individual has an obituary indication on record. |
NAME_MISMATCH | The provided name does not match official records. |
BIRTHDATE_MISMATCH | The provided date of birth does not match official records. |
TAX_ID_NOT_FOUND | The provided tax identification number was not found. |
TAX_ID_IRREGULAR | The tax identification number has an irregular status. |
UNDERAGE | The individual is under the minimum required age. |
PEP | The individual is a Politically Exposed Person. |
SANCTIONS | The individual is on a current sanctions list. |
KYC_STATUS_FAILED | The identity verification could not be completed. |
CRIMINAL_LAWSUITS | Criminal lawsuits were detected for the individual. |
EXCESSIVE_LAWSUITS | An excessive number of lawsuits were detected. |
DOCUMENT_UNREADABLE | The submitted document could not be read or processed. |
DOCUMENT_NAME_MISMATCH | The name on the document does not match the provided name. |
INCONCLUSIVE | The verification was inconclusive. Contact support. |
COMPROMISED_PERSONS | Identity could not be verified due to compromised person records. |
ADVERSE_MEDIA | Adverse media findings were detected. |
CRIMINAL | Criminal records were found. |
DOCUMENT_IRREGULARITY | The submitted document has irregularities. |
SELFIE_MISMATCH | The selfie does not match the document photo. |
FRAUDULENT_LIVENESS | The liveness verification detected fraudulent behavior. |
NOT_DOCUMENT | The submitted file is not a valid identity document. |
FACE_MATCH_FAILED | The selfie does not match the face on the submitted document. |
SOURCE_FACE_CONFIDENCE_TOO_LOW | The face in the source image could not be detected with sufficient confidence. |
IMAGE_QUALITY_INSUFFICIENT | The image quality is too low for face comparison. |
FACE_POSE_NOT_SUITABLE | The face pose in the image is not suitable for verification. |
FACE_PARTIALLY_OBSTRUCTED | The face in the image is partially obstructed. |
NO_FACE_DETECTED | No face was detected in the submitted document. |
IMAGE_TOO_BLURRY | The submitted image is too blurry for verification. |
IMAGE_OVEREXPOSED | The submitted image is too bright for verification. |
IMAGE_UNDEREXPOSED | The submitted image is too dark for verification. |
DICT_MARK | The user is considered high risk by Brazilian financial institutions. |
These labels describe the provider's decision. Do not expose sensitive screening details unnecessarily; show the user a clear next step, such as correcting personal data, submitting a readable document, or contacting support for an inconclusive or risk-related result.
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. rejectionLabels is the stable enum your UI can map. |
EXPIRED | Documents aged out. Submit a new attempt. |
Sandbox onboarding
Onboarding is mirrored on sandbox-api.hodle.com.br: subaccount creation, document
upload, KYC submit and the attempt polls all run there against the same handlers production
uses, so request and response shapes are identical. See
Sandbox for the full coverage table.
The outcome is simulated: pass sandboxReject: true in POST /api/kyc to force a REJECTED
attempt (rejectionReason: "SANDBOX_SIMULATED"), or omit it to get APPROVED. No real
identity data is sent anywhere.
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.
Once KYB is APPROVED, you can:
- raise the company's operating limits by submitting Proof of Address for the company and its UBO plus Proof of Financial Capacity — all three must be approved, and
- unlock USD operations by submitting Proof of Financial Capacity (e.g. a bank statement) — the same submission that counts toward the higher limits.
Third-party operations
Every ramp operation moves funds for a taxholder. The operation endpoints — POST /api/deposit/asset and POST /api/wallet/payout — accept a taxId that identifies the beneficiary CPF behind the movement.
| Field | Type | Required | Description |
|---|---|---|---|
taxId | string | No | 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.
The gate reads the taxId you declare, not the owner of the PIX key or BR Code. Declare the real beneficiary on every operation that is not for the account holder — that is what keeps your reporting accurate and your account compliant.
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" }Contact support to enable third-party operations. It is a per-account switch (support clears the account's THIRD_PARTY_DISABLED flag), applied to the account behind your API key — not a request parameter and not something a quote's outputBrCode grants. If paying a beneficiary other than the account holder is your core flow rather than an edge case, say so when you ask, and confirm it is on before you build against it.
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.