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).- Webhooks
KYC_APPROVED/KYC_REJECTED— 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_APPROVED/KYC_REJECTEDwebhook 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...",
"livenessIdentityPoolId": "eu-west-1:...",
"livenessRegion": "eu-west-1"
}
}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.
By default 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. When the second verification is enabled for your account, the page runs two captures in a row: the provider's, then one on Hodle's own AWS account, whose reference image is kept privately until the second partner decides (see below).
Rendering the capture on your own screen instead of the page? See Liveness capture in your own app.
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.
Liveness capture in your own app
Use this when you render
AWS Amplify's FaceLivenessDetector
on your own screen instead of sending the user to livenessUrl. Everything the detector needs
comes from POST /api/kyc/document with documentType: "SELFIE-FROM-LIVENESS", so your app
hardcodes nothing.
Session fields
| Field | Use it for |
|---|---|
sessionId | The detector's sessionId. |
livenessIdentityPoolId | The Cognito guest identity pool that issues credentials for the session. |
livenessRegion | The detector's region, and the Cognito region. |
validateLivenessToken | Confirming the capture (and the credentials endpoint below). |
{
"success": true,
"data": {
"documentId": "doc_b21...",
"sessionId": "0ee149a1-...",
"validateLivenessToken": "eyJhbGciOi...",
"livenessIdentityPoolId": "us-east-1:...",
"livenessRegion": "us-east-1"
}
}Read livenessIdentityPoolId and livenessRegion from every response — never hardcode
them. The three values always belong to the same session, and the pool and region can
change per account and per session. A hardcoded pool or region fails to open a session that
lives elsewhere.
Credentials
Get guest credentials from the pool in the response — two public calls, no API key:
const cognito = async (target: string, body: object) => {
const response = await fetch(`https://cognito-identity.${livenessRegion}.amazonaws.com/`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-amz-json-1.1',
'X-Amz-Target': `AWSCognitoIdentityService.${target}`,
},
body: JSON.stringify(body),
})
return response.json()
}
const credentialProvider = async () => {
const { IdentityId } = await cognito('GetId', { IdentityPoolId: livenessIdentityPoolId })
const { Credentials } = await cognito('GetCredentialsForIdentity', { IdentityId })
return {
accessKeyId: Credentials.AccessKeyId,
secretAccessKey: Credentials.SecretKey,
sessionToken: Credentials.SessionToken,
expiration: new Date(Credentials.Expiration * 1000),
}
}The credentials are short-lived and can only stream a liveness capture into a session our server created.
Alternative — POST /api/public/liveness/credentials. Send the validateLivenessToken
and we return the credentials and the region for that session:
curl --request POST \
--url https://api.hodle.com.br/api/public/liveness/credentials \
--header "Content-Type: application/json" \
--data '{ "validateLivenessToken": "eyJhbGciOi..." }'{
"success": true,
"data": {
"accessKeyId": "ASIA...",
"secretAccessKey": "...",
"sessionToken": "...",
"expiration": "2026-09-26T12:15:00.000Z",
"region": "us-east-1"
}
}The token is the only credential — no API key, any origin (CORS is open). The endpoint is rate
limited per IP and answers 400 without a token and 502 when the credentials cannot be
issued; retry after a short pause.
Rendering the detector
<FaceLivenessDetectorCore
sessionId={sessionId}
region={livenessRegion}
config={{ credentialProvider }}
onAnalysisComplete={confirmCapture}
/>The session expires a few minutes after POST /api/kyc/document creates it — open the camera
right away. If it expires, register a new SELFIE-FROM-LIVENESS document.
Confirming the capture
When onAnalysisComplete fires, confirm the capture with the same token:
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 — and the call can come from the browser or your
server. 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. Then send documentId as uploadedSelfieId in POST /api/kyc.
Retrying a capture
A capture can complete and still not count: validate answers 400 with
"error": "Liveness not approved", or "error": "Liveness confidence below threshold" when
the face was not clear enough (usually poor light, light behind the user, glasses or a hat).
Let the user retry on the spot, with the same token and the same documentId:
curl --request POST \
--url https://api.hodle.com.br/api/public/liveness/hodle/session \
--header "Content-Type: application/json" \
--data '{ "token": "eyJhbGciOi..." }'It answers { "success": true, "data": { "sessionId": "..." } }. Remount the detector with
the new sessionId, then call validate again. A document whose liveness already succeeded
answers alreadyCompleted: true. The token lasts 30 minutes, so the retry also replaces an
expired session. Our hosted livenessUrl page already offers this as "Tentar novamente".
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. |
addressNumber | string | No | Street number on its own (e.g. 1000). Only used by the second verification; the primary verification keeps reading streetAddress. Sent as S/N when omitted. |
addressComplement | string | No | Apartment, suite, block. Same scope as addressNumber. |
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...",
"subAccountId": "a928c33c-1ad2-4bf9-b4ce-1f83b403f1e4",
"status": "PENDING",
"createdAt": "2026-05-09T22:00:00.000Z"
}
}Customer already verified by Hodle
A tax id can hold only one verified identity. When the person behind taxIdNumber is already verified at Hodle (a Hodle app or dashboard customer, or a subaccount of any integrator), a new verification is not opened. The subaccount is linked to the identity that is already verified, and the call answers 200 with the final state:
{
"success": true,
"data": {
"attemptId": "link_3f9c1e2a-...",
"subAccountId": "a928c33c-1ad2-4bf9-b4ce-1f83b403f1e4",
"status": "APPROVED",
"linked": true,
"createdAt": "2026-09-30T12:00:00.000Z"
}
}The link is made only when fullName matches the name on the verified identity (case, accents and spacing are ignored). Any number of subaccounts can be linked to the same verified identity. The link has its own attemptId: submitting the same subaccount again answers the same attemptId. Each link (and each resubmission) also sends a KYC_APPROVED webhook with the subAccountId, the attemptId and linked: true, so your webhook handler needs no special case. The webhook is sent synchronously, before the 200 comes back. GET /api/kyc/{attemptId} reads that attemptId like any other attempt, and GET /api/kyc?subAccountId=... answers status: "APPROVED" with linked: true and the same lastAttemptId for as long as that identity stays verified. When nothing at Hodle matches, the call answers 400 cannot repeat taxId across multiple users, as before.
Errors
{
"success": false,
"error": "Validation failed",
"details": [{ "field": "taxIdNumber", "message": "must contain only digits" }]
}{ "success": false, "error": "cannot repeat taxId across multiple users" }{ "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_APPROVED / KYC_REJECTED 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_APPROVED / KYC_REJECTED 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.
For accounts with the second verification enabled, both
GET /api/kyc/{attemptId} and GET /api/kyc also return providers, the status of each
verification that looked at the attempt. Accounts without it do not get the field at all. The
top-level status is always the primary verification's and keeps its meaning; providers only
adds detail.
"providers": [
{ "provider": "PRIMARY", "status": "APPROVED" },
{ "provider": "SECONDARY", "status": "PROVIDER_PENDING", "updatedAt": "2026-05-09T22:03:10.000Z" }
]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. |
Second verification
Some accounts are enabled for a second verification: once the primary verification approves an attempt, Hodle forwards the same evidence — the identity document, the liveness reference image and the approved personal data — to a second verification partner, so the subaccount is also cleared there without the user repeating anything. It is off unless support enables it for your account, and it never runs in the sandbox (the sandbox has no real identity to forward).
What changes for you when it is on:
-
The liveness page runs two captures in a row (see Liveness selfies). The user stays on the same page.
-
Running the capture on your own screen?
POST /api/kyc/documentwithSELFIE-FROM-LIVENESSalso returnssecondaryLivenessTokenandsecondaryLivenessRegion. After the first capture validates, run the second one:POST /api/public/liveness/hodle/sessionwith{ "token": secondaryLivenessToken }→{ "data": { "sessionId" } }. Call it right before the capture: a session expires minutes after it is created.POST /api/public/liveness/hodle/credentialswith the same body → temporary AWS credentials (accessKeyId,secretAccessKey,sessionToken,expiration) for theFaceLivenessDetectorcredentialProvider, withregion: secondaryLivenessRegion.- When the detector finishes,
POST /api/public/liveness/hodle/completewith the same body.
The second capture never blocks the primary verification: if it fails or is skipped, only the second verification ends as
SKIPPED. -
POST /api/kycacceptsaddressNumberandaddressComplement. Send them: the second partner needs the street number as a separate field. -
KYC webhooks carry
provider:PRIMARYon the events of the primary verification. When second-verification webhooks are also enabled for your account, the second decision arrives as anotherKYC_APPROVEDorKYC_REJECTEDfor the sameattemptIdwithprovider: "SECONDARY"(see Webhooks). -
providersappears onGET /api/kyc/{attemptId}andGET /api/kyc: aPRIMARYentry and, once the attempt is forwarded, aSECONDARYone.
The top-level status is still the primary verification's. The primary approval alone is
what lets the subaccount transact; the second verification never downgrades it.
providers[].status (SECONDARY) | Meaning |
|---|---|
WAITING_PRIMARY | Waiting for the primary verification to decide. |
SUBMITTING | Forwarding the evidence. |
PROVIDER_PENDING | Under review by the second partner. |
APPROVED | Cleared by the second partner. |
REJECTED | Rejected. reason.code says why. |
REJECTED_RETRYABLE | Rejected, but a new attempt can fix it. reason.retryFrom says what to redo. |
SKIPPED | Never forwarded: the primary verification did not approve, or the evidence was incomplete (e.g. the selfie was not a liveness capture). |
FAILED | Could not be forwarded (evidence refused or partner unavailable). reason.code says why. |
reason, when present, is { "code", "retryable", "retryFrom" }: branch on code.
PROVIDER_REJECTED (retryable: false) means the partner's own KYC provider issued a final
rejection, such as a compliance screening hit. A new attempt with the same person fails the
same way, so after it POST /api/kyc for that subaccount answers 409 with
code: "KYC_PROVIDER_REJECTED" and the provider's text in reason, and nothing is submitted.
Do not offer the customer a retry.
PROVIDER_EVIDENCE_REJECTED (retryable: true) means the provider refused the images, not the
person (e.g. "no face detected in image", "selfie does not match document"): ask the customer
for new photos and a new liveness, then submit again. Both codes carry the provider's text in the
KYC_REJECTED webhook reason.
The evidence Hodle keeps for this lives in private storage, is deleted as soon as the second partner decides (or the attempt is skipped), and is erased after 7 days at the latest.
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_APPROVED/KYC_REJECTEDwebhook 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 setting, 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 one of KYC_APPROVED,
KYC_REJECTED, KYC_EXPIRED or KYC_FAILED (there is no kyc.completed event). Register a
webhook for each event you want in the dashboard:
{
"event": "KYC_APPROVED",
"data": {
"attemptId": "att_8f3a...",
"subAccountId": "a928c33c-1ad2-4bf9-b4ce-1f83b403f1e4",
"userId": "6a70b6364c38f215e99b8c8b",
"level": "level-1",
"approvedAt": "2026-05-08T22:31:11.000Z"
}
}A subaccount linked to an existing verification gets the
same KYC_APPROVED, with linked: true, right after POST /api/kyc.
For accounts with the second verification enabled, the subaccount KYC
events carry a provider field (PRIMARY for the primary verification, SECONDARY for the
second one). See Webhooks for the exact payloads and signature verification.