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 (PENDINGAPPROVED or REJECTED).
  • Webhook kyc.completed — pushed when the attempt resolves. See Webhooks.

A subaccount is allowed to transact (/api/deposit/asset, /api/wallet/payout) 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.completed 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..."
  }
}

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.

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"
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.

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 prefixSP, not BR-SP. A prefixed value fails with InvalidFieldError: state is invalid.
citystringYesCity of residence.
zipCodestringYesPostal code.
streetAddressstringYesStreet + number + complement.
uploadedSelfieIdstringYesdocumentId of the uploaded selfie, from POST /api/kyc/document.
uploadedDocumentIdstringYesdocumentId of the uploaded identity document.
sandboxRejectbooleanNoSandbox only. true 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...",
    "status": "PENDING",
    "createdAt": "2026-05-09T22:00:00.000Z"
  }
}

Errors

400 — validation
{
  "success": false,
  "error": "Validation failed",
  "details": [{ "field": "taxIdNumber", "message": "must contain only digits" }]
}
409 — user already approved
{ "success": false, "error": "User has an APPROVED KYC attempt" }

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

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

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.

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.

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.completed webhook or poll GET /api/kyc/{attemptId} until status: APPROVED.

POST /api/kyb

Request

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

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

Parameters

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

Response

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

Errors

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

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

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