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
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}'
{"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:
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"
| 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 |
{"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:
{"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)
curl -X POST https://api.flonk.id/v1/verifications/cm5abc123def456/face \-H "Authorization: Bearer $FLONK_SECRET_KEY" \-F "images=@/path/to/selfie.jpg"
{"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:
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"}'
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"
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"}}
- 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:
{"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:
{"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:
curl https://api.flonk.id/v1/verifications/cm5abc123def456 \-H "Authorization: Bearer $FLONK_SECRET_KEY"
{"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:
{"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:
{"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:
{"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
#!/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/consent/{sessionId} | Record the user's explicit consent — { textVersion, locale }; see below |
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, 5 consent records, 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.
Recording consent
Identity documents and selfies are biometric data — Art. 9 GDPR — and where your lawful basis is consent, you need to be able to show it was given: an affirmative action, before the first capture, naming what is processed.
The hosted widget shows that screen and calls this endpoint for you. If you build your own capture UI, the screen is yours to build, and this endpoint is how you record the result:
POST /v1/kyc/consent/{sessionId}Authorization: Bearer <embedToken>{ "textVersion": "2026-08-31", "locale": "de" }
textVersion names the wording the user agreed to — bump it whenever your consent copy changes, and keep the texts, or the record cannot say what was consented to. IP and user agent are read from the request; don't send your own.
Call it once, after the user's affirmative action and before the first upload. It sets userConsent and consentTimestamp on the session.