Flonk
Flonk Docs

Direct API (No SDK)

Run the full KYC flow with plain REST calls — no widget, no SDK: create a session, upload documents, upload the face, submit, receive the webhook or poll the result.

Last updated: 8/19/2026
5 min read

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 embedToken against the widget endpoints. See Device-side uploads below.

Before you start (one-time setup)

  1. Create an account at panel.flonk.id and complete onboarding.
  2. Create a project. Every project gets two environments — Sandbox and Live — each with its own publishable + secret key pair (Dashboard → API Keys).
  3. Moderation. Sandbox works immediately. Live session creation requires your project to be approved — until then POST /v1/sessions with a live key returns 403 with the current moderationStatus.
  4. Balance. Verifications are billed per completed session. With no trial and no balance the API returns 402 Payment Required on session creation — top up in Dashboard → Billing.
  5. 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.
  6. (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

curl -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
}'
bash
{
"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
}
json
ParameterTypeRequiredDescription
clientMetadataobjectNoAny key-value pairs (e.g. email, userId) — returned in webhook events so you can match verifications to your users
expiryMinutesnumberNoSession lifetime in minutes, 1–60 (default: 5)
languagestringNoen, 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 expiryMinutes deliberately. The default is 5 minutes and the maximum is 60. Every step below — including the user taking photos — must finish before expiresAt, otherwise the API starts returning KYC_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:

curl -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"
bash
FieldTypeRequiredDescription
imageTypestringYesfront, back, or main
imagesfileYesJPG/PNG, max 10 MB
isRetakebooleanNotrue replaces a previously uploaded image of the same side
{
"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."
}
json

The document type is auto-detected — you don't declare it upfront. Follow the response:

  • requiresBackSide: true → repeat the call with imageType=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 with details.reasonCode of WRONG_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:

  1. First occurrence on a given side returns the usual retake-style error — the same document-processing envelope as today (the KYC_008 / KYC_014 family), with details.reasonCode naming 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.
  2. 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 to manual_review at submit, where a human makes the final call.
  3. 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:

{
"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" }]
}
json

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)

curl -X POST https://api.flonk.id/v1/verifications/cm5abc123def456/face \
-H "Authorization: Bearer $FLONK_SECRET_KEY" \
-F "images=@/path/to/selfie.jpg"
bash
{
"success": true,
"confidence": 1,
"message": "Face captured successfully",
"readyForFinalVerification": true
}
json

This upload triggers two biometric checks:

  1. 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.
  2. 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).

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:

curl -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"
}'
bash

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

curl -X POST https://api.flonk.id/v1/verifications/cm5abc123def456/submit \
-H "Authorization: Bearer $FLONK_SECRET_KEY"
bash

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:

{
"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"
}
}
json
  • 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 submit response carries a trimmed field set; the full result — including documentNumber, issueDate, expiryDate, placeOfBirth, and face-match details — comes from GET /v1/verifications/{sessionId} or the webhook.
  • Calling it again while processing is in flight is rejected; calling it on an already completed session returns the cached result.
  • When processing finishes, the verification.completed webhook fires to the URL you configured.

The submit response now always carries a statuscompleted, 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:

{
"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" }]
}
json

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:

{
"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" }]
}
json

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:

  1. Check retryable on the error (and the failureReason in GET /v1/verifications/{sessionId}). Quality, liveness, and face-match failures are retryable; billing errors (KYC_023/KYC_024) are not.
  2. 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).
  3. Call /submit again. 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 stateReopens on the next write call?What to do
failedYes — automatically within TTLRe-upload everything and submit again
action_requiredYes — 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_reviewNo — frozen while a human reviewsWait for the decision (webhook + GET). The session does not expire on TTL and rejects all further uploads
rejectedNo — closed permanentlyCreate a new session; a rejection is final
completedNo — closed permanentlyA successful verification can never be re-run or overwritten
supersededNo — collapsed automaticallyAn 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 in manual_review, the older review is moved to superseded the moment the newer attempt reaches review or completes — so a reviewer only ever sees one open item per person per environment. A superseded session behaves like a closed one: it triggers no webhook, no email and no billing.

Proof of Address: if your environment has poaRequiredForVerification enabled, 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:

curl https://api.flonk.id/v1/verifications/cm5abc123def456 \
-H "Authorization: Bearer $FLONK_SECRET_KEY"
bash
{
"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"
}
}
json

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_number by design), this authenticated pull endpoint includes documentNumber — 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 GET instead.
  • GET /v1/verifications/{sessionId} returns status: "manual_review" for the duration of the review:
{
"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" }]
}
json
  • The top-level issues array is the same user-safe, sanitized set as the submit response (quality codes named, fraud/integrity collapsed to a single GENERIC_REVIEW; code only). 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 decisionWebhook firedResulting GET status
Approveverification.completed — the standard event, identical to an automatic approval (confidence, extracted data, duplicate signal)completed
Rejectverification.status_changed with status: "rejected", plus rejection_reason and reviewed_byrejected

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:

EndpointLimit / min
POST …/documents, POST …/face120
POST …/submit30
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:

{
"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" }
}
json

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:

{
"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"
}
json

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:

CodeHTTPMeaningRetryable
KYC_001404Session not found in your projectnew session
KYC_003400Session expired mid-flownew session
KYC_006400Image failed validation (type/size)yes
KYC_007400Image quality too low — check details.reasonCode (WRONG_SIDE_BACK_UPLOADED, PORTRAIT_NOT_DETECTED, …)yes
KYC_008b422Liveness check failedyes, new selfie
KYC_009400Document type requires both sidesupload back
KYC_013400Document expireddifferent document
KYC_015400Verification confidence too lowclearer images
KYC_020400Selfie doesn't match document portraityes
KYC_021400No face detected in selfieyes
KYC_022400Selfie quality too lowyes
KYC_023 / KYC_024402Balance / credit limit — top upno

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

#!/usr/bin/env bash
set -euo pipefail
API=https://api.flonk.id
AUTH="Authorization: Bearer $FLONK_SECRET_KEY"
# 1. Create a session
SESSION_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" ]; then
curl -sf -X POST $API/v1/verifications/$SESSION_ID/documents \
-H "$AUTH" -F "imageType=back" -F "images=@id-back.jpg" > /dev/null
fi
# 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 10
curl -sf $API/v1/verifications/$SESSION_ID -H "$AUTH" | jq .verification.status
bash

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>
EndpointPurpose
POST /v1/kyc/upload-documentDocument image — multipart sessionId, imageType, images, isRetake?
POST /v1/kyc/upload-faceSelfie — multipart sessionId, images
POST /v1/kyc/check_liveness_base64Stateless 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-addressPoA 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.