Direct API (No SDK, No Widget)
Everything the Flonk widget does goes through the same verification pipeline — and you can drive it yourself with four REST calls authenticated by your secret key. Use the Direct API when you want verification under your own control:
- Native mobile apps (iOS / Android) with your own camera UI
- Fully custom web UI where the widget doesn't fit
- Backend-driven flows — you already have the user's document images and want to verify them programmatically
The widget/SDK is still the recommended default. It handles camera capture, auto-capture quality gates, retake guidance, localization, and mobile hand-off for you. With the Direct API, you own capture quality — most rejections in custom integrations come from blurry, cropped, or glare-heavy photos that the widget would have caught before upload.
All project-level configuration — branding-independent settings like Proof of Address requirements, vault reuse policy, manual-upload permission — applies to Direct API sessions exactly as it does to widget sessions: it is the same session, the same pipeline, and the same webhook.
Attribution requirement
A Direct API integration replaces Flonk's own UI, so your users never see who performs the verification. You must display a clearly visible "KYC powered by Flonk" notice — text or the Flonk logo, ideally linking to flonk.id — on the screens where your users go through identity verification (document capture, selfie, and processing/result steps).
- The notice must be legible and permanently visible on those screens — not hidden behind menus, tooltips, or settings.
- If the attribution is missing, Flonk reserves the right to issue an official written request to add it; failure to comply after such a request constitutes a breach of the Terms of Service and may lead to suspension of API access.
- A white-label integration (no attribution) is possible only under a separate written agreement — contact support@flonk.id.
Widget-based integrations are not affected — the widget carries the notice itself.
Privacy disclosure (always required, including white-label). Independent of attribution, your privacy policy must disclose Flonk as your identity verification provider — Flonk processes identity documents and biometric data on your behalf, and your users have a right to know. Suggested language:
We use Flonk (flonk.id) for identity verification. Flonk collects and processes the identity document and facial images you submit in order to verify your identity, acting as a data processor on our behalf.
How the flow works
One-time setup, then one credential and four API calls per verification:
Your server Flonk API─────────── ─────────1. POST /v1/sessions ─────────── sk_live_* ──► session created◄── { id, expiresAt, ... }2. POST /v1/verifications/{id}/documents ── sk ──► front side◄── { requiresBackSide: true }POST /v1/verifications/{id}/documents ── sk ──► back side (if required)3. POST /v1/verifications/{id}/face ─────── sk ──► selfie (liveness + face match)4. POST /v1/verifications/{id}/submit ───── sk ──► processing starts◄────────── webhook: verification.completed ──────────GET /v1/verifications/{id} ────────────── sk ──► status + extracted data (polling)
The whole server-side flow uses a single credential:
Authorization: Bearer sk_live_...
Base URL: https://api.flonk.id. Requests can be version-pinned with the optional Flonk-Version header — see Authentication.
Uploading from the end-user's device instead of your server? Don't ship the secret key to a device — use the session-bound
embedTokenagainst the widget endpoints. See Device-side uploads below.
Before you start (one-time setup)
- Create an account at panel.flonk.id and complete onboarding.
- Create a project. Every project gets two environments — Sandbox and Live — each with its own publishable + secret key pair (Dashboard → API Keys).
- Moderation. Sandbox works immediately. Live session creation requires your project to be approved — until then
POST /v1/sessionswith a live key returns403with the currentmoderationStatus. - Balance. Verifications are billed per completed session. With no trial and no balance the API returns
402 Payment Requiredon session creation — top up in Dashboard → Billing. - Configure your webhook in Dashboard → Settings → Webhooks: add the endpoint URL that will receive verification results and store the webhook secret (
whsec_*). This is the primary way you get results — set it up before going live. Full guide: Webhooks. - (Optional) Branding — logo and colors configured in the dashboard apply to the hosted widget and QR fallback pages. A pure Direct API integration never shows Flonk UI, so this step only matters if you mix in widget-based flows.
Step 1 — Create a session
bashcurl -X POST https://api.flonk.id/v1/sessions \-H "Authorization: Bearer $FLONK_SECRET_KEY" \-H "Content-Type: application/json" \-H "Idempotency-Key: $(uuidgen)" \-d '{"clientMetadata": { "email": "user@example.com", "userId": "user_123" },"expiryMinutes": 30}'
json{"id": "cm5abc123def456","status": "pending","embedToken": "eyJhbGciOiJIUzI1NiIs...","expiresAt": "2026-01-15T12:30:00.000Z","createdAt": "2026-01-15T12:00:00.000Z","widgetUrl": "https://verify.flonk.id/?sessionId=...","qrCodeUrl": "https://verify.flonk.id/?sessionId=...","testMode": false}
| Parameter | Type | Required | Description |
|---|---|---|---|
clientMetadata | object | No | Any key-value pairs (e.g. email, userId) — returned in webhook events so you can match verifications to your users |
expiryMinutes | number | No | Session lifetime in minutes, 1–60 (default: 5) |
language | string | No | en, de, uk — only affects the hosted widget UI; irrelevant for a pure Direct API flow |
For the server-side flow you only need id — every /v1/verifications/* call below authenticates with the same secret key. (embedToken and widgetUrl are still returned, so the very same session can alternatively be finished in the widget or via device-side uploads — the flows are interchangeable per session.)
Set
expiryMinutesdeliberately. The default is 5 minutes and the maximum is 60. Every step below — including the user taking photos — must finish beforeexpiresAt, otherwise the API starts returningKYC_003(session expired) and you must create a new session.
All parameters, idempotency semantics, and the reuse behaviour are documented in the Sessions API.
Step 2 — Upload document images
Upload the front side first (the side with the portrait), as multipart/form-data:
bashcurl -X POST https://api.flonk.id/v1/verifications/cm5abc123def456/documents \-H "Authorization: Bearer $FLONK_SECRET_KEY" \-F "imageType=front" \-F "images=@/path/to/id-front.jpg"
| Field | Type | Required | Description |
|---|---|---|---|
imageType | string | Yes | front, back, or main |
images | file | Yes | JPG/PNG, max 10 MB |
isRetake | boolean | No | true replaces a previously uploaded image of the same side |
json{"success": true,"detectedDocumentType": "id_card","detectionConfidence": 0.9,"uploadedImageType": "front","isValidDocument": true,"requiresBackSide": true,"uploadedSides": { "front": true },"readyForVerification": false,"nextStepMessage": "Front side uploaded successfully. Please upload the back side of your ID card."}
The document type is auto-detected — you don't declare it upfront. Follow the response:
requiresBackSide: true→ repeat the call withimageType=back(ID cards, driver licenses). Passports are single-sided.readyForVerification: true→ all document sides are in; move on to the face step.
Image requirements the widget normally enforces for you — now your job:
- The front image must clearly show the portrait. A back side uploaded as
front(or a front with an undetectable portrait) comes back withdetails.reasonCodeofWRONG_SIDE_BACK_UPLOADED/PORTRAIT_NOT_DETECTED— retake the correct image. See Recoverable problems for what happens if the same problem repeats. - Submitting a double-sided document without its back side fails at submit with
KYC_009(retryable) — upload the back and resubmit. - Sharp focus, no glare over the data fields or MRZ, all four corners in frame.
To replace an image, send it again with isRetake=true.
Recoverable problems: retake once, then accept-and-record
A handful of upload problems are recoverable quality issues rather than hard failures — a wrong side, two sides that don't look like the same document, a low MRZ cross-check score, or low image quality. The API gives the user one chance to fix each, then stops blocking so a genuine edge case can still get through:
- First occurrence on a given side returns the usual retake-style error — the same document-processing envelope as today (the
KYC_008/KYC_014family), withdetails.reasonCodenaming the exact problem (WRONG_SIDE_BACK_UPLOADED,PORTRAIT_NOT_DETECTED,MRZ_CROSS_LOW_SCORE,DOCUMENT_MIXING_SUSPECTED,LOW_DETECTION_CONFIDENCE, …). Re-upload that side with a better image. - The same problem again on the next upload of that side: the API accepts the image (HTTP
200), records the problem internally, and lets the flow continue. That session no longer auto-approves — it is routed tomanual_reviewat submit, where a human makes the final call. - A clean re-upload of a side clears the problems recorded for it, so a genuine fix still auto-approves.
Face liveness is the exception. A non-live selfie (
KYC_008b,422) is always rejected at the face step and never accepted-and-recorded — liveness is a fraud gate, so the user must retake until a live face is confirmed. See Step 3.
The issues field
Whenever a problem is recorded on an upload, the response carries an optional issues array so a server-to-server integrator can coach the end user in real time. Each entry is { code, scope }, where scope is front, back, main, or face:
json{"success": true,"detectedDocumentType": "id_card","detectionConfidence": 0.55,"uploadedImageType": "front","isValidDocument": true,"requiresBackSide": true,"uploadedSides": { "front": true },"readyForVerification": false,"nextStepMessage": "Front side uploaded. Some quality issues were noted.","issues": [{ "code": "WRONG_SIDE_BACK_UPLOADED", "scope": "front" }]}
The field is additive — it is absent when the upload is clean. Treat it as an advisory signal: a session that still carries issues at submit routes to manual review, so surfacing them lets your UI nudge the user to reshoot before they get there.
Step 3 — Upload the face (selfie)
bashcurl -X POST https://api.flonk.id/v1/verifications/cm5abc123def456/face \-H "Authorization: Bearer $FLONK_SECRET_KEY" \-F "images=@/path/to/selfie.jpg"
json{"success": true,"confidence": 1,"message": "Face captured successfully","readyForFinalVerification": true}
This upload triggers two biometric checks:
- Liveness (single-image) — static anti-spoofing on the uploaded frame: printed photos, screen replays, and rendered/generated faces are rejected right here with
422/KYC_008b(see Errors). This is photo-based liveness — the Direct API does not require a video or an active challenge from your UI. - Face match — does the selfie match the portrait on the document uploaded in Step 2?
Remaining failures surface at submit (Step 4) as KYC_020 (face mismatch), KYC_021 (no face detected), or KYC_022 / KYC_008b (quality / liveness).
Recommended: attach device signals
In a Direct API flow Flonk never sees the end-user's device, so fraud analysis loses the signals the widget collects automatically. Pass what your capture surface knows — it takes one call and strengthens every downstream check:
bashcurl -X POST https://api.flonk.id/v1/verifications/cm5abc123def456/device-data \-H "Authorization: Bearer $FLONK_SECRET_KEY" \-H "Content-Type: application/json" \-d '{"ipAddress": "203.0.113.42","userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 ...)","timezone": "Europe/Berlin","language": "de-DE","platform": "iOS"}'
All fields are optional strings: ipAddress, userAgent, fingerprint (your device-fingerprint ID), timezone, language, platform, screenResolution. Call it any time before submit; repeated calls merge.
Step 4 — Submit the verification
bashcurl -X POST https://api.flonk.id/v1/verifications/cm5abc123def456/submit \-H "Authorization: Bearer $FLONK_SECRET_KEY"
This runs the full verification pipeline on everything uploaded so far: document authenticity and OCR, MRZ/VIZ cross-checks, document expiry, the liveness gate, face match, and confidence scoring. When processing completes synchronously, the response already carries the outcome and the core extracted fields:
json{"success": true,"status": "completed","timestamp": "2026-01-15T12:05:43.359Z","confidence": 0.8849,"data": {"documentType": "id_card","extractedData": {"fullName": "KOVALENKO OLEKSANDR","firstName": "OLEKSANDR","lastName": "KOVALENKO","dateOfBirth": "1995-03-12","nationality": "UKR","sex": "M"},"verificationAttemptId": "atm_1a2b3c4d5e"}}
- Depending on deployment processing mode, the response may instead be
{ "success": true, "status": "queued", "jobId": "..." }— the result then arrives via webhook or polling. Treat both shapes as valid. - The
submitresponse carries a trimmed field set; the full result — includingdocumentNumber,issueDate,expiryDate,placeOfBirth, and face-match details — comes fromGET /v1/verifications/{sessionId}or the webhook. - Calling it again while processing is in flight is rejected; calling it on an already
completedsession returns the cached result. - When processing finishes, the
verification.completedwebhook fires to the URL you configured.
The submit response now always carries a status — completed, failed, manual_review, or action_required. When the pipeline can't confidently auto-approve, the session enters review rather than failing, and submit returns success: false with no extracted-data payload — the data (or the rejection) arrives after a human decides:
json{"success": false,"status": "manual_review","timestamp": "2026-01-15T12:05:43.359Z","message": "Verification is under review","issues": [{ "code": "DOCUMENT_QUALITY_TOO_LOW" }, { "code": "GENERIC_REVIEW" }]}
status: "failed" keeps its existing retake-and-resubmit meaning; status: "manual_review" is a review outcome for this call — see Manual review for the lifecycle.
A distinct action_required status means a required document side is missing — the user photographed the wrong side twice, or a back side that carries no MRZ. A reviewer can't review what was never uploaded, so instead the response names the single side to re-capture in an additive nextAction field (resubmit_front or resubmit_back). Re-upload only that side, then submit again:
json{"success": false,"status": "action_required","nextAction": "resubmit_back","timestamp": "2026-01-15T12:05:43.359Z","message": "An additional document image is required","issues": [{ "code": "MRZ_MISSING_BACK_SIDE" }]}
An action_required session is not frozen: its TTL is extended to 24 hours so the user has time to act, and it stays resubmittable exactly like the failed-reopen path (no auto status flip on GET — the resubmission happens through the normal upload + submit calls). After 2 re-collection cycles the session falls through to manual_review instead of looping. GET /v1/verifications/{sessionId} echoes both status: "action_required" and nextAction.
The additive issues array on a manual_review response is user-safe: it is sanitized server-side so you can show it directly to the end user. Obvious, actionable problems are named with their code (DOCUMENT_QUALITY_TOO_LOW, MRZ_DATA_UNREADABLE, DOC_EXPIRED, WRONG_SIDE_BACK_UPLOADED, PORTRAIT_NOT_DETECTED, FACE_MATCH_LOW_SCORE, …); every anti-fraud signal is deliberately collapsed into a single GENERIC_REVIEW line — the API never discloses which anti-fraud check tripped. Entries carry code only (no scope, no scores), and the array is never empty: a review with no recorded codes still carries the GENERIC_REVIEW line. The same sanitized array is returned by GET /v1/verifications/{sessionId} while the session is under review.
Resubmission after a failed attempt
A failed attempt does not burn the session. As long as the session hasn't expired, the Direct API automatically reopens a failed session on your next call, so you can fix what went wrong and try again — no new session, no new expiryMinutes budget:
- Check
retryableon the error (and thefailureReasoninGET /v1/verifications/{sessionId}). Quality, liveness, and face-match failures are retryable; billing errors (KYC_023/KYC_024) are not. - Re-upload all images — both document sides and the selfie. When a session fails, previously captured images and extracted state are purged for privacy, so the retry starts from a clean slate (front first, as usual).
- Call
/submitagain. Attempt history is preserved; the webhook fires for the attempt that finally completes, and the verification is billed only once — on completion.
The automatic reopen above applies only to failed. The other terminal and frozen states behave differently:
| Session state | Reopens on the next write call? | What to do |
|---|---|---|
failed | Yes — automatically within TTL | Re-upload everything and submit again |
action_required | Yes — resubmittable (24 h TTL, no status flip) | Re-upload only the side named by nextAction (resubmit_front/resubmit_back), then submit again. Uploads of any other side or the selfie are rejected. After 2 cycles it becomes manual_review |
manual_review | No — frozen while a human reviews | Wait for the decision (webhook + GET). The session does not expire on TTL and rejects all further uploads |
rejected | No — closed permanently | Create a new session; a rejection is final |
completed | No — closed permanently | A successful verification can never be re-run or overwritten |
superseded | No — collapsed automatically | An older review that the same person replaced with a newer attempt (which reached review or completed) in the same environment. No decision webhook, email or billing fires for it. Follow the newer session |
The key contrast: a failed session auto-reopens so you can retake, but a rejected session (a human said no) is closed for good — there is no resubmission, only a fresh session.
Duplicate reviews are collapsed. If the same person (matched on
clientMetadata.email) starts over while an earlier attempt is still inmanual_review, the older review is moved tosupersededthe moment the newer attempt reaches review or completes — so a reviewer only ever sees one open item per person per environment. Asupersededsession behaves like a closed one: it triggers no webhook, no email and no billing.
Proof of Address: if your environment has
poaRequiredForVerificationenabled, upload the PoA document before submitting — currently via the device-side endpoints (POST /v1/kyc/upload-proof-of-address, PDF supported). The webhook is then held until PoA processing completes — see the Proof of Address guide.
Getting the result
Webhooks are the primary channel — the verification.completed event carries the decision, confidence, and extracted data the moment processing finishes. Verify the HMAC signature before trusting it: Webhooks.
For polling or reconciliation, fetch the result directly:
bashcurl https://api.flonk.id/v1/verifications/cm5abc123def456 \-H "Authorization: Bearer $FLONK_SECRET_KEY"
json{"sessionId": "cm5abc123def456","status": "completed","testMode": false,"clientMetadata": { "email": "user@example.com", "userId": "user_123" },"createdAt": "2026-01-15T12:00:00.000Z","expiresAt": "2026-01-15T12:30:00.000Z","updatedAt": "2026-01-15T12:05:00.000Z","verification": {"id": "atm_1a2b3c4d5e","status": "success","confidence": 0.8849,"documentType": "id_card","faceMatchStatus": "MATCHED","faceMatchScore": 0.8245,"poaStatus": null,"failureReason": null,"isReused": false,"extractedData": {"fullName": "KOVALENKO OLEKSANDR","firstName": "OLEKSANDR","lastName": "KOVALENKO","dateOfBirth": "1995-03-12","nationality": "UKR","sex": "M","documentNumber": "012345678","issueDate": "2021-04-10","expiryDate": "2031-04-10","placeOfBirth": "M. KYIV"},"createdAt": "2026-01-15T12:04:00.000Z"}}
verification is null until processing has produced an attempt. The session status moves pending → connected → processing → completed | failed | manual_review | action_required; an action_required session returns to processing on the next submit, and a manual_review decision later resolves to completed or rejected; the attempt verification.status reports the verification outcome. The full session status set is pending, connected, processing, completed, failed, expired, manual_review, action_required, rejected, superseded.
Unlike the pushed webhook payload (which omits
document_numberby design), this authenticated pull endpoint includesdocumentNumber— the same data your team sees in the dashboard.
Manual review
When automated checks can't confidently approve — low confidence scores, side mismatches, a flagged selfie, or any of the recorded issues above — the session doesn't fail. It enters manual_review, and a human reviewer approves or rejects it.
While a session is under review
- No webhook fires when a session enters review. Nothing is pushed at this point, so don't wait on an event — poll
GETinstead. GET /v1/verifications/{sessionId}returnsstatus: "manual_review"for the duration of the review:
json{"sessionId": "cm5abc123def456","status": "manual_review","testMode": false,"clientMetadata": { "email": "user@example.com", "userId": "user_123" },"createdAt": "2026-01-15T12:00:00.000Z","expiresAt": "2026-01-15T12:30:00.000Z","updatedAt": "2026-01-15T12:06:00.000Z","verification": {"id": "atm_1a2b3c4d5e","status": "manual_review","confidence": 0.6104,"failureReason": null},"issues": [{ "code": "DOCUMENT_QUALITY_TOO_LOW" }, { "code": "GENERIC_REVIEW" }]}
- The top-level
issuesarray is the same user-safe, sanitized set as thesubmitresponse (quality codes named, fraud/integrity collapsed to a singleGENERIC_REVIEW;codeonly). It is present only while the session is under review and only when there is something to show. - A session in review does not expire on TTL (reviewers can take days) and rejects all further uploads — it is frozen until the decision lands. Keep polling
GET.
The decision
| Reviewer decision | Webhook fired | Resulting GET status |
|---|---|---|
| Approve | verification.completed — the standard event, identical to an automatic approval (confidence, extracted data, duplicate signal) | completed |
| Reject | verification.status_changed with status: "rejected", plus rejection_reason and reviewed_by | rejected |
Both outcomes reuse events you already handle — approval is a normal verification.completed, rejection is a normal verification.status_changed — so no new webhook type is introduced. Wire up those two handlers and review outcomes flow through your existing code. See Webhooks → verification.status_changed for the reject payload.
A rejected session is closed permanently — unlike failed, it never reopens. Create a new session to try again.
Rate limits
/v1/verifications/* limits are per project environment, per minute:
| Endpoint | Limit / min |
|---|---|
POST …/documents, POST …/face | 120 |
POST …/submit | 30 |
GET /v1/verifications/{id} | 300 |
Session creation limits are documented in Authentication → Rate Limits. Exceeding a limit returns 429.
Errors
/v1/verifications/* endpoints return a structured envelope with a stable errorCode:
json{"statusCode": 400,"timestamp": "2026-01-15T12:10:00.000Z","path": "/v1/verifications/cm5abc123def456/submit","errorCode": "KYC_007","retryable": true,"message": "The image quality is too low to be processed. Please provide a clear, focused image.","details": { "reasonCode": "WRONG_SIDE_BACK_UPLOADED" }}
details is optional and comes in two shapes: a structured object (as above), or — for liveness rejections — a machine-readable reason string. A non-live selfie rejected at the face step looks like this:
json{"statusCode": 422,"timestamp": "2026-01-15T12:07:31.728Z","path": "/v1/verifications/cm5abc123def456/face","errorCode": "KYC_008b","retryable": true,"message": "We could not confirm a live face. Please retry with your face fully visible.","details": "REASON|liveness_failed|low_liveness_score"}
Branch on errorCode (and details.reasonCode / the REASON| string where present), never on message text. retryable tells you whether re-uploading and resubmitting on the same session can succeed. The codes you will actually see:
| Code | HTTP | Meaning | Retryable |
|---|---|---|---|
KYC_001 | 404 | Session not found in your project | new session |
KYC_003 | 400 | Session expired mid-flow | new session |
KYC_006 | 400 | Image failed validation (type/size) | yes |
KYC_007 | 400 | Image quality too low — check details.reasonCode (WRONG_SIDE_BACK_UPLOADED, PORTRAIT_NOT_DETECTED, …) | yes |
KYC_008b | 422 | Liveness check failed | yes, new selfie |
KYC_009 | 400 | Document type requires both sides | upload back |
KYC_013 | 400 | Document expired | different document |
KYC_015 | 400 | Verification confidence too low | clearer images |
KYC_020 | 400 | Selfie doesn't match document portrait | yes |
KYC_021 | 400 | No face detected in selfie | yes |
KYC_022 | 400 | Selfie quality too low | yes |
KYC_023 / KYC_024 | 402 | Balance / credit limit — top up | no |
The recoverable document problems (KYC_007 low quality and the KYC_008 / KYC_014 document-processing family) all carry a details.reasonCode. On the second identical occurrence of a side's problem the API stops returning the error and instead accepts the image, records the issue, and routes the session to manual review at submit — see Recoverable problems. Liveness (KYC_008b) is never accepted this way.
/v1/sessions* endpoints use the flat error format described in Errors.
Test mode
Use sk_test_* keys to exercise the whole flow without real AI processing or (by default) billing. Test sessions resolve to predefined personas keyed by clientMetadata.email — john.doe@example.com (approved), jane.smith@example.com (approved), fail@example.com (rejected). On a test session you can call submit immediately after creating it — no uploads needed — and webhooks still fire, so you can test your receiver end-to-end.
Full example
bash#!/usr/bin/env bashset -euo pipefailAPI=https://api.flonk.idAUTH="Authorization: Bearer $FLONK_SECRET_KEY"# 1. Create a sessionSESSION_ID=$(curl -sf -X POST $API/v1/sessions \-H "$AUTH" -H "Content-Type: application/json" \-d '{"clientMetadata":{"email":"user@example.com","userId":"user_123"},"expiryMinutes":30}' \| jq -r .id)# 2. Upload document front (repeat with imageType=back if requiresBackSide=true)FRONT=$(curl -sf -X POST $API/v1/verifications/$SESSION_ID/documents \-H "$AUTH" -F "imageType=front" -F "images=@id-front.jpg")if [ "$(echo "$FRONT" | jq -r .requiresBackSide)" = "true" ]; thencurl -sf -X POST $API/v1/verifications/$SESSION_ID/documents \-H "$AUTH" -F "imageType=back" -F "images=@id-back.jpg" > /dev/nullfi# 3. Upload selfie (liveness + face match run in background)curl -sf -X POST $API/v1/verifications/$SESSION_ID/face \-H "$AUTH" -F "images=@selfie.jpg" > /dev/null# 4. Submit — result arrives via webhook, or poll:curl -sf -X POST $API/v1/verifications/$SESSION_ID/submit -H "$AUTH"sleep 10curl -sf $API/v1/verifications/$SESSION_ID -H "$AUTH" | jq .verification.status
Device-side uploads (embedToken)
If images are captured on the end-user's device and you don't want to proxy them through your backend, don't embed the secret key in the app — use the embedToken returned by POST /v1/sessions. It is a session-bound JWT that expires with the session (it's the same token the widget uses), so it is safe on the device:
Authorization: Bearer <embedToken>
| Endpoint | Purpose |
|---|---|
POST /v1/kyc/upload-document | Document image — multipart sessionId, imageType, images, isRetake? |
POST /v1/kyc/upload-face | Selfie — multipart sessionId, images |
POST /v1/kyc/check_liveness_base64 | Stateless liveness pre-check { base64 } → { isLive, confidence } — fail fast before committing a selfie; liveness is enforced again at submit regardless |
POST /v1/kyc/upload-proof-of-address | PoA document (PDF or image); /async variant + GET /v1/kyc/poa-status/{jobId} for polling |
POST /v1/kyc/complete-verification/{sessionId} | Same as submit |
These endpoints are rate-limited per session (3-minute window: 20 document uploads, 15 face/liveness, 6 PoA, 8 completes) and return the same KYC_0xx error envelope. You can mix freely with the server-side flow — e.g. device uploads the images with embedToken, then your backend calls POST /v1/verifications/{id}/submit and receives the webhook.