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 and get a one-time upload URL, or — for the selfie — a hosted liveness session. 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/kyc/import-token — reuse a Sumsub verification you already ran yourself: post a share token instead of documents, 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 (PENDING → APPROVED or REJECTED).
  • 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" }'
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 the ID photo in your UI.
  2. POST /api/kyc/document with documentType: "ID" (or DRIVERS-LICENSE / PASSPORT) — upload the image to the returned URL. See Uploading documents.
  3. POST /api/kyc/document with documentType: "SELFIE-FROM-LIVENESS" — send the user to the returned livenessUrl to record the selfie. See Liveness selfies.
  4. POST /api/kyc — submit the personal data referencing the two documentIds. Hodle returns an attemptId.
  5. Wait for either the KYC_APPROVED / KYC_REJECTED webhook or poll GET /api/kyc/{attemptId} every ~30 seconds.
  6. 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" }'
FieldTypeRequiredDescription
subAccountIdstringYesThe subaccount the document belongs to.
documentTypestringYesSELFIE-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.
isDoubleSidedbooleanNoSet true for documents with a back side (e.g. most national IDs). Returns a second URL.

The response shape depends on the documentType.

201 Created — ID / DRIVERS-LICENSE / PASSPORT
{
  "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.

201 Created — SELFIE-FROM-LIVENESS
{
  "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:

  1. 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.
  2. Poll GET /api/kyc/document/{documentId} until ready: true. Before the capture it reports ready: false with uploadStatusFront: "WAITING-UPLOAD".
  3. Use that documentId as uploadedSelfieId in POST /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.

201 Created — SELFIE-FROM-LIVENESS
{
  "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"
200 OK
{
  "success": true,
  "data": {
    "documentId": "doc_b21...",
    "ready": true,
    "documentType": "SELFIE",
    "uploadStatusFront": "COMPLETED"
  }
}
FieldDescription
readytrue when the documentId can be used in POST /api/kyc.
uploadStatusFrontPENDING, PROCESSING, COMPLETED or EXPIRED. Omitted when the provider is silent.
uploadErrorFrontPresent 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

FieldUse it for
sessionIdThe detector's sessionId.
livenessIdentityPoolIdThe Cognito guest identity pool that issues credentials for the session.
livenessRegionThe detector's region, and the Cognito region.
validateLivenessTokenConfirming the capture (and the credentials endpoint below).
201 Created — SELFIE-FROM-LIVENESS
{
  "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..." }'
200 OK
{
  "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

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 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).
taxIdNumberstringYesThe 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.
emailstringYesMust match the user's email on file.
phonestringNoE.164 (+5511...).
countrystringYesISO-3 country code of residence.
statestringYesFederative 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.
citystringYesCity of residence.
zipCodestringYesPostal code.
streetAddressstringYesStreet + number + complement.
addressNumberstringNoStreet 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.
addressComplementstringNoApartment, suite, block. Same scope as addressNumber.
uploadedSelfieIdstringYesdocumentId of the uploaded selfie, from POST /api/kyc/document.
uploadedDocumentIdstringYesdocumentId of the uploaded identity document.
sandboxRejectbooleanNoSandbox 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

202 Accepted
{
  "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:

200 OK — linked to an existing verification
{
  "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

400 — validation
{
  "success": false,
  "error": "Validation failed",
  "details": [{ "field": "taxIdNumber", "message": "must contain only digits" }]
}
400 — tax id verified elsewhere, not linkable
{ "success": false, "error": "cannot repeat taxId across multiple users" }
409 — user already approved
{ "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

FieldTypeRequiredDescription
subAccountIdstringNoThe subaccount to import the verification into. Omit to import into the API-key account itself. Must be an INDIVIDUAL subaccount you created.
importTokenstringYesThe 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

202 Accepted
{
  "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.

errorCodeStatusMeaningWhat to do
INVALID_IMPORT_TOKEN400Token missing, empty, malformed, expired, or rejected by Sumsub.Mint a fresh token for a GREEN applicant.
IMPORT_TOKEN_ALREADY_USED409That 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_PROGRESS409A previous import for this subaccount is still running.Poll GET /api/kyc/{attemptId} until it resolves.
IMPORT_NOT_ENABLED502Sumsub import is not enabled for this account.Contact Hodle to enable it.
IMPORT_FAILED400The provider refused the import for another reason (error carries the text).Read error; retry only if it describes something transient.
400 — no token sent
{
  "success": false,
  "error": "Validation failed",
  "errorCode": "INVALID_IMPORT_TOKEN",
  "details": [{ "field": "importToken", "message": "importToken is required" }]
}
400 — token rejected
{
  "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"
}
409 — token already consumed
{
  "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

status: APPROVED
{
  "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"
  }
}
status: REJECTED
{
  "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
"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.

LabelMeaning
OBIT_INDICATIONThe individual has an obituary indication on record.
NAME_MISMATCHThe provided name does not match official records.
BIRTHDATE_MISMATCHThe provided date of birth does not match official records.
TAX_ID_NOT_FOUNDThe provided tax identification number was not found.
TAX_ID_IRREGULARThe tax identification number has an irregular status.
UNDERAGEThe individual is under the minimum required age.
PEPThe individual is a Politically Exposed Person.
SANCTIONSThe individual is on a current sanctions list.
KYC_STATUS_FAILEDThe identity verification could not be completed.
CRIMINAL_LAWSUITSCriminal lawsuits were detected for the individual.
EXCESSIVE_LAWSUITSAn excessive number of lawsuits were detected.
DOCUMENT_UNREADABLEThe submitted document could not be read or processed.
DOCUMENT_NAME_MISMATCHThe name on the document does not match the provided name.
INCONCLUSIVEThe verification was inconclusive. Contact support.
COMPROMISED_PERSONSIdentity could not be verified due to compromised person records.
ADVERSE_MEDIAAdverse media findings were detected.
CRIMINALCriminal records were found.
DOCUMENT_IRREGULARITYThe submitted document has irregularities.
SELFIE_MISMATCHThe selfie does not match the document photo.
FRAUDULENT_LIVENESSThe liveness verification detected fraudulent behavior.
NOT_DOCUMENTThe submitted file is not a valid identity document.
FACE_MATCH_FAILEDThe selfie does not match the face on the submitted document.
SOURCE_FACE_CONFIDENCE_TOO_LOWThe face in the source image could not be detected with sufficient confidence.
IMAGE_QUALITY_INSUFFICIENTThe image quality is too low for face comparison.
FACE_POSE_NOT_SUITABLEThe face pose in the image is not suitable for verification.
FACE_PARTIALLY_OBSTRUCTEDThe face in the image is partially obstructed.
NO_FACE_DETECTEDNo face was detected in the submitted document.
IMAGE_TOO_BLURRYThe submitted image is too blurry for verification.
IMAGE_OVEREXPOSEDThe submitted image is too bright for verification.
IMAGE_UNDEREXPOSEDThe submitted image is too dark for verification.
DICT_MARKThe 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

StatusMeaning
PENDINGUnder review. Keep polling or wait for the webhook.
APPROVEDUser cleared. Transact endpoints will accept this user now.
REJECTEDFailed. rejectionLabels is the stable enum your UI can map.
EXPIREDDocuments 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/document with SELFIE-FROM-LIVENESS also returns secondaryLivenessToken and secondaryLivenessRegion. After the first capture validates, run the second one:

    1. POST /api/public/liveness/hodle/session with { "token": secondaryLivenessToken } → { "data": { "sessionId" } }. Call it right before the capture: a session expires minutes after it is created.
    2. POST /api/public/liveness/hodle/credentials with the same body → temporary AWS credentials (accessKeyId, secretAccessKey, sessionToken, expiration) for the FaceLivenessDetector credentialProvider, with region: secondaryLivenessRegion.
    3. When the detector finishes, POST /api/public/liveness/hodle/complete with 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/kyc accepts addressNumber and addressComplement. Send them: the second partner needs the street number as a separate field.

  • KYC webhooks carry provider: PRIMARY on the events of the primary verification. When second-verification webhooks are also enabled for your account, the second decision arrives as another KYC_APPROVED or KYC_REJECTED for the same attemptId with provider: "SECONDARY" (see Webhooks).

  • providers appears on GET /api/kyc/{attemptId} and GET /api/kyc: a PRIMARY entry and, once the attempt is forwarded, a SECONDARY one.

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_PRIMARYWaiting for the primary verification to decide.
SUBMITTINGForwarding the evidence.
PROVIDER_PENDINGUnder review by the second partner.
APPROVEDCleared by the second partner.
REJECTEDRejected. reason.code says why.
REJECTED_RETRYABLERejected, but a new attempt can fix it. reason.retryFrom says what to redo.
SKIPPEDNever forwarded: the primary verification did not approve, or the evidence was incomplete (e.g. the selfie was not a liveness capture).
FAILEDCould 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

  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_APPROVED / KYC_REJECTED 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.

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.

FieldTypeRequiredDescription
taxIdstringNoCPF 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.

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:

403 — third party not enabled
{ "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.