Identity verification (KYC)
Every customer completes identity verification before they can operate. Pre-submit the profile you already hold, launch the hosted verification flow with a single call, and track the outcome by polling or webhooks — the more data you send, the less your customer has to type.
server-side integrators
API External API v1
Two calls to integrate
You'll need workspace credentials and a bearer token — see the Authentication guide. Drive the whole flow against https://api-stage.virtuabroker.com first; stage behaves like production but verifies no real identities.
How it works
Verification has two parts, and only one of them can be pre-filled by you:
- Profile & AML data — name, contact, address, employment, origin of funds. This can come from you over the API, or be typed by the customer in the hosted form.
- Identity check — document capture and selfie. Always completed by the customer in the hosted flow; it cannot be pre-submitted.
The hosted flow adapts to how much profile data is already on file. The more you push ahead of time with POST /v1/kyc/data, the shorter the form your customer sees:
No data sent
The customer fills the full profile form, then completes the document + selfie check.
Partial data sent
The form asks only the fields still missing, then moves to the document + selfie check.
All required data sent
No form at all — the customer goes straight to the document + selfie check.
Recommended integration. Collect the profile in your own onboarding UI (or reuse what you already hold), push it with POST /v1/kyc/data, then start verification — your customers go straight to the document + selfie step with no duplicate form.
Prerequisites & authentication
All endpoints require a bearer token. Exchange your workspace credentials for one with the OAuth 2.0 password grant, then send it on every request as Authorization: Bearer <access_token>. See the Authentication guide for the token lifecycle.
export VB_TOKEN=$(curl -s https://auth2.virtuabroker.com/realms/cryptobot/protocol/openid-connect/token \ -d "grant_type=password" \ -d "client_id=$VB_CLIENT_ID" \ -d "username=$VB_USERNAME" \ -d "password=$VB_PASSWORD" | jq -r .access_token)
Customers are identified by their email address. No prior registration call is needed — an account is created on first use. In the request body the customer's email is userEmail; on the status read the path parameter is accountEmail.
1 · Pre-submit customer data optional · recommended
POST /v1/kyc/data sends any subset of the customer's profile. Calls merge: you can send data incrementally across several calls, and each response reports exactly which required fields are still missing. Re-sending a field overwrites it; nested objects (addresses, document, taxId, depositEstimation) are replaced whole, so always send them complete.
curl https://api.virtuabroker.com/v1/kyc/data \ -H "Authorization: Bearer $VB_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "userEmail": "maria@example.com", "firstName": "María", "lastName": "González", "phone": "+34600000000", "birthdate": "1990-01-31", "birthCountry": "ES", "nationality": "ES", "employmentStatus": "employed", "profession": "Nurse", "fundsOrigin": "salary", "accountPurpose": "currencyExchange", "depositEstimation": { "minimum": 0, "maximum": 10000 }, "addresses": [{ "street": "Calle Mayor 1", "city": "Madrid", "state": "Madrid", "zip": "28001", "country": "ES" }] }'
const res = await fetch("https://api.virtuabroker.com/v1/kyc/data", { method: "POST", headers: { Authorization: `Bearer ${access_token}`, "Content-Type": "application/json" }, body: JSON.stringify({ userEmail: "maria@example.com", firstName: "María", lastName: "González", phone: "+34600000000", nationality: "ES", employmentStatus: "employed", fundsOrigin: "salary", accountPurpose: "currencyExchange", addresses: [{ street: "Calle Mayor 1", city: "Madrid", state: "Madrid", zip: "28001", country: "ES" }], }), }); const data = await res.json();
data = requests.post("https://api.virtuabroker.com/v1/kyc/data", headers={"Authorization": f"Bearer {access_token}"}, json={ "userEmail": "maria@example.com", "firstName": "María", "lastName": "González", "phone": "+34600000000", "nationality": "ES", "employmentStatus": "employed", "fundsOrigin": "salary", "accountPurpose": "currencyExchange", "addresses": [{"street": "Calle Mayor 1", "city": "Madrid", "state": "Madrid", "zip": "28001", "country": "ES"}], }, ).json()
{
"success": true,
"providedFields": ["firstName", "lastName", "phone", "nationality",
"employmentStatus", "fundsOrigin", "accountPurpose", "addresses"],
"missingFields": []
}
When missingFields is empty the hosted flow will skip the profile form entirely. missingFields is the authoritative source of what is still needed — read it after every call rather than assuming a fixed list.
Profile fields
The fields below make up the profile. Whether a given field is required depends on your workspace's verification provider — the exact required set is provider-dependent. A lighter provider may not require birthdate, birthCountry, accountPurpose, profession, or depositEstimation; another may require none of them up front. Do not hard-code the list below as an API-wide requirement — trust the missingFields array in the responses instead.
| Field | Type / format | Notes |
|---|---|---|
firstName, lastName | string | Legal name, as on the ID document |
birthdate | ISO 8601 date | e.g. 1990-01-31 |
birthCountry | ISO 3166-1 alpha-2 | Country of birth |
phone | string, E.164 | e.g. +34600000000 |
nationality | ISO 3166-1 alpha-2 | Two letters, not three — ES, VE |
employmentStatus | enum | See values below |
profession | string | Free text, e.g. Nurse |
fundsOrigin | enum | See values below |
accountPurpose | enum | See values below |
depositEstimation | object | { "minimum": <int>, "maximum": <int> } — expected deposit volume |
addresses | array, ≥ 1 | Each with street, city, state, zip, country (ISO-2) |
Conditional & optional fields
| Field | Notes | |
|---|---|---|
fundsOriginOthersReason | CONDITIONAL | Required when fundsOrigin is others — free-text description |
gender | OPTIONAL | male · female · N/D (confirmed from the ID document) |
document | CONDITIONAL | { "type", "id", "expiryDate", "issuingCountry" } — the block is optional (the hosted flow captures the document), but when you send it all four fields are required. Types: idcard · passport · drivinglicense · visa; issuingCountry ISO-2 |
taxId | OPTIONAL | { "number", "country" } — tax identifier |
pep | OPTIONAL | boolean — politically exposed person self-declaration |
Enumerated values
| Field | Accepted values |
|---|---|
employmentStatus | employed · selfEmployed · businessOwner · unemployed · student · retired · others |
fundsOrigin | salary · incomes · investments · pensions · donations · propertiesSell · others |
accountPurpose | savings · currencyExchange · investment · trading · cryptocurrencyPayments |
Document policy is set by compliance, not the API. Some jurisdictions restrict which documents are accepted — for example, residents of Spain may be required to verify with a national ID (DNI/NIF) or foreigner ID (NIE) rather than a passport. These rules come from your workspace's verification provider and compliance configuration; the API itself does not restrict document type by residency. Confirm the accepted documents for each market with your account contact.
2 · Start the verification
POST /v1/kyc returns the kycUrl your customer opens to complete verification. Pass your return URLs here — the customer is redirected to them when the hosted flow finishes.
curl https://api.virtuabroker.com/v1/kyc \ -H "Authorization: Bearer $VB_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "userEmail": "maria@example.com", "countryCode": "ES", "type": "INDIVIDUAL", "sendOTPMail": false, "language": "es", "successUrl": "https://yourapp.example.com/kyc/success", "errorUrl": "https://yourapp.example.com/kyc/error" }'
const res = await fetch("https://api.virtuabroker.com/v1/kyc", { method: "POST", headers: { Authorization: `Bearer ${access_token}`, "Content-Type": "application/json" }, body: JSON.stringify({ userEmail: "maria@example.com", countryCode: "ES", type: "INDIVIDUAL", sendOTPMail: false, language: "es", successUrl: "https://yourapp.example.com/kyc/success", errorUrl: "https://yourapp.example.com/kyc/error", }), }); const kyc = await res.json();
kyc = requests.post("https://api.virtuabroker.com/v1/kyc", headers={"Authorization": f"Bearer {access_token}"}, json={ "userEmail": "maria@example.com", "countryCode": "ES", "type": "INDIVIDUAL", "sendOTPMail": False, "language": "es", "successUrl": "https://yourapp.example.com/kyc/success", "errorUrl": "https://yourapp.example.com/kyc/error", }, ).json()
| Field | Notes | |
|---|---|---|
userEmail | REQUIRED | The customer's email |
countryCode | REQUIRED | ISO 3166-1 alpha-2 — pre-selects the document country |
type | REQUIRED | INDIVIDUAL |
sendOTPMail | REQUIRED | boolean — whether the hosted flow sends the customer a one-time email code |
language | OPTIONAL | UI language — e.g. en, es, fr, de, pt, it; any locale code your verification provider's WebSDK supports is accepted (not a fixed enum). Defaults to en when omitted or blank. |
successUrl | OPTIONAL | Where the customer is redirected after completing verification |
errorUrl | OPTIONAL | Where the customer is redirected if verification fails or is cancelled |
blank | OPTIONAL | true renders the page without framing — ideal for embedding in a WebView |
{
"success": true,
"status": "PENDING",
"kycUrl": "https://api.virtuabroker.com/kyc/widget?data=…",
"kycId": "maria@example.com",
"userEmail": "maria@example.com"
}
- Open
kycUrlin a browser tab, a redirect, or a WebView. Generate a fresh URL per session — don't store it. - If required profile data is still needed first,
POST /v1/kycmay return amissingFieldsarray and omitkycUrl— push the remaining fields withPOST /v1/kyc/data, then callPOST /v1/kycagain. - If the customer is already
APPROVED, the response carries that status and nokycUrl.
3 · Your customer completes verification
What the customer sees at kycUrl depends on how much you pre-submitted in step 1:
| Data on file | Customer experience |
|---|---|
| All required fields | Straight to the identity check — document photos + selfie. No form. |
| Some fields | A short form asking only the missing fields, then the identity check. |
| None | The full profile form, then the identity check. |
The hosted pages are responsive and mobile-friendly, with searchable selectors and keyboard navigation built in. When the flow ends, the customer is redirected to your successUrl or errorUrl.
Approval is not always instant at redirect. Document review can take from seconds to a few minutes. Treat successUrl as "submission finished" and rely on the webhook (step 5) or a poll (step 4) for the final decision.
4 · Track the verification status
GET /v1/kyc/{accountEmail} returns the current verification status wrapped in the standard { ok, value } envelope.
curl https://api.virtuabroker.com/v1/kyc/maria@example.com \ -H "Authorization: Bearer $VB_TOKEN"
{
"ok": true,
"value": {
"status": "IN_PROGRESS",
"missingFields": [],
"accountId": "acct_5f3c9a2e"
}
}
value.accountId is present once an account exists for the customer. Keep it — it's the identifier you pass to later deposit-method, deposit, and order calls, so the verified customer and their operations line up.
The status is one of six external values. Poll here, or subscribe to webhooks to react the moment it becomes APPROVED or REJECTED:
| Status | Meaning | Terminal |
|---|---|---|
NOT_AVAILABLE | No verification exists for this customer yet. | No |
PENDING | Verification created, but the customer hasn't started. | No |
IN_PROGRESS | The customer is completing the flow, or the submission is being processed. | No |
REVIEWING | The submitted information is under review. | No |
APPROVED | Fully verified — the customer can operate. | Yes |
REJECTED | Verification did not pass. Follow up through your account contact to resolve. | Yes |
value.missingFields lists any profile fields still not on file — useful to prompt the customer, or your own systems, for exactly what's left.
5 · Receive status webhooks
Register an HTTPS endpoint once and VirtuaBroker notifies you on every verification status change — no polling needed. Verification changes arrive as the verification_status_updated event. For the full webhook contract — the event catalog, envelope shape, and delivery guarantees — see the Webhooks guide.
POST /v1/webhooks registers an endpoint. You supply the signing secret (minimum 8 characters) in the request — VirtuaBroker signs every delivery with it and never generates or returns it. Keep it in a secret store.
curl https://api.virtuabroker.com/v1/webhooks \ -H "Authorization: Bearer $VB_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Production", "url": "https://yourapp.example.com/webhooks/vb", "secret": "a-strong-random-secret" }'
Each delivery carries an X-VirtuaBroker-Signature-256 header: the string sha256= followed by the hex HMAC-SHA256 of the raw request body, keyed with the secret you supplied. Verify it before trusting the payload — recompute the value over the exact bytes received and compare against the full header value (including the sha256= prefix) with a constant-time comparison.
import crypto from "node:crypto"; function verify(rawBody, header, secret) { const expected = "sha256=" + crypto .createHmac("sha256", secret) .update(rawBody, "utf8") .digest("hex"); const a = Buffer.from(expected); const b = Buffer.from(header || ""); return a.length === b.length && crypto.timingSafeEqual(a, b); }
import hashlib, hmac def verify(raw_body: bytes, header: str, secret: str) -> bool: expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, header or "")
Compute the HMAC over the raw request body, exactly as received, and compare against the whole header string — not a bare hex. Re-serializing parsed JSON reorders keys and whitespace, which changes the bytes and breaks verification.
The verification_status_updated payload for a KYC change looks like this:
verification_status_updated (KYC)
{
"eventId": "b2c3d4e5-6f70-4a1b-8c2d-3e4f5a6b7c8d",
"occurredAt": 1710000000000,
"verificationType": "KYC",
"customerEmail": "maria@example.com",
"entityId": "entity-1",
"status": "APPROVED",
"previousStatus": "REVIEWING"
}
Deliveries may occasionally repeat — de-duplicate on eventId, and always confirm the latest state with GET /v1/kyc/{accountEmail} before granting access.
End-to-end sequence
The recommended integration, end to end. Steps 1 and 6 are optional but make the experience seamless and the state authoritative.
Pre-submit the profile
POST /v1/kyc/data with the profile you already hold. Read missingFields to see what's left; keep sending until it's empty.
Start verification
POST /v1/kyc with successUrl and errorUrl. The response returns the kycUrl.
Send the customer to the hosted flow
Open kycUrl via redirect or WebView. The customer fills any missing fields, then completes the document + selfie check and is redirected back to your successUrl.
Verification is reviewed
The submission is processed and, where needed, reviewed. This can take from seconds to a few minutes — don't block on the redirect.
React to the webhook
A verification_status_updated event arrives when the status changes — e.g. to APPROVED. Verify the signature and de-duplicate on eventId.
Confirm and grant access
GET /v1/kyc/{accountEmail} to confirm APPROVED authoritatively before enabling operations for the customer.
Error handling & best practices
Errors
| HTTP | Cause | What to do |
|---|---|---|
400 | Validation failed — unknown enum value, malformed field, bad country code | Fix the field named in the response and retry — e.g. nationality must be 2-letter ISO (VE, not VEN) |
401 | Missing or expired token | Re-authenticate and retry |
5xx | Temporary failure | Retry with backoff; POST /v1/kyc/data and POST /v1/kyc are safe to re-call |
Best practices
- Send data before starting. Call
/v1/kyc/dataas soon as you have the customer's profile — even partially — so the hosted form shrinks or disappears. - Send nested objects whole.
addresses,document,taxId, anddepositEstimationreplace the previous value entirely. - Trust
missingFields. It's returned by both/v1/kyc/dataandGET /v1/kyc/{accountEmail}and always reflects what's still needed — the required set is provider-dependent, so don't hard-code it. - Generate a fresh
kycUrlper attempt. Links are session-bound; re-callPOST /v1/kycwhen the customer returns. - Gate on
APPROVED. Treat the webhook as the signal andGET /v1/kyc/{accountEmail}as the source of truth before enabling operations.
Quick reference
| Endpoint | Purpose |
|---|---|
POST /v1/kyc/data | Pre-submit / update customer profile data (merges; returns missingFields) |
POST /v1/kyc | Start verification; returns kycUrl plus successUrl / errorUrl redirects |
GET /v1/kyc/{accountEmail} | Current status + missingFields + accountId, wrapped in { ok, value } |
POST /v1/webhooks | Register a signed webhook endpoint for verification_status_updated |