# idcheck.now API — full reference (llms-full.txt) > Complete machine-readable reference for the idcheck.now identity-verification (KYC) API. > Human docs: https://idcheck.now/docs/ — OpenAPI: https://idcheck.now/openapi.json idcheck.now verifies US driver's licenses / state IDs with AAMVA PDF417 barcode forensics (live client decode + independent server re-decode), selfie + passive liveness, face matching, OFAC SDN sanctions screening with DOB corroboration, crypto wallet screening, and 1:N duplicate-face fraud detection. Decisions are scored 0–100 with explainable reason strings and a hash-chained audit log per verification. ## Base URLs - REST API (all /v1/* endpoints): https://idcheck.now - Hosted verification flow: https://idcheck.now/verify/{verification_id}?token={client_token} - SDK hosted origin (default baseUrl): https://verify.idcheck.now - All traffic HTTPS + JSON (UTF-8). ## Authentication Two credential types: 1. API key (server-to-server). Header: `X-Api-Key: idc_...` - Keys are prefixed `idc_`; only the SHA-256 hash is stored; plaintext shown once at creation. - Scopes (wildcard `*` grants all): - verifications:write → POST /v1/verifications - verifications:read → GET /v1/verifications, GET /v1/verifications/{id}, GET /v1/verifications/{id}/media - screen:ofac → POST /v1/screen/ofac, POST /v1/screen/wallet, GET /v1/screen/ofac/stats - keys:write → POST /v1/keys, POST /v1/keys/{key_id}/revoke - keys:read → GET /v1/keys - webhooks:write → POST /v1/webhooks, DELETE /v1/webhooks/{endpoint_id} - webhooks:read → GET /v1/webhooks - Missing/unknown/revoked key → 401 `invalid API key`. Missing scope → 403 `missing scope: `. 2. Client token (end-user browser, hosted flow). Header: `X-Client-Token: ` (or `Authorization: Bearer `). - Opaque, single-verification, 30-minute TTL (1800 s). Issued by POST /v1/verifications. - Invalid/expired → 401 `invalid or expired client token`. - Never expose API keys in browser code; the client token is the only browser credential. Webhook authentication (server → merchant): every delivery has header `X-IdCheck-Signature: t=,v1=` where the HMAC key is the endpoint `secret` (returned once at webhook creation) and the message is `"."`. Verify against the RAW body bytes with a constant-time comparison; reject stale timestamps. ## Verification lifecycle created → consented → doc_front → doc_back → selfie → submitted → processing → decided - Verification expires 24 h after creation (`expires_at`). - Client token expires after 30 min. - Decision computed synchronously at POST /v1/session/submit. ## Decision engine Score 0–100 from weighted signals; bands: >= 80 approve, 50–79.99 review, < 50 decline. Signal weights (max 100): - barcode_parse_ok: 10 (pass/fail) - mandatory_elements: 15 (pass/fail) - not_expired: 15 (pass/fail) - license_recompute: 10 (pass/fail; FL/IL/WI only, auto-pass elsewhere) - server_client_barcode_match: 10 (pass/fail; neutral full points if server decode unavailable) - face_match: 25 × face_match_score (threshold 0.6) - liveness: 15 × liveness_score (threshold 0.5) Overrides (can only lower the decision): - ML degraded (face/liveness unavailable): approve capped at review (`ml_degraded_capped_at_review`). - OFAC: strong → decline (`ofac_match`); possible → cap review (`ofac_possible_match`); unavailable → cap review (`ofac_screen_unavailable`). Never fails open. - Face dedupe: different identity → decline (`face_duplicate_different_identity`); seen before → cap review (`face_seen_before`); unavailable → cap review (`face_dedupe_unavailable`). Reason strings (decision_reasons): barcode_parse_failed, mandatory_elements_missing, document_expired, license_number_recompute_failed, server_client_barcode_mismatch, server_barcode_decode_unavailable, face_match_below_threshold, face_match_unavailable, liveness_below_threshold, liveness_unavailable, ml_degraded_capped_at_review, ofac_match, ofac_possible_match, ofac_screen_unavailable, face_duplicate_different_identity, face_seen_before, face_dedupe_unavailable. doc_checks keys on a verification: mandatory_elements, dob_parseable, expiry_parseable, not_expired, dob_in_past, date_ordering, age_over_18, license_recompute, server_client_barcode_match, tamper_signal, no_server_image, ofac, face_dedupe, device, passed. face_dedupe.reason ∈ face_duplicate_different_identity | face_seen_before | returning_customer | below_threshold | no_matches | unavailable. ## Endpoints ### POST /v1/verifications — create a verification (scope: verifications:write) → 201 Request: { "external_ref": string|null, "user": object (default {}), "redirect_url": string|null } Response: { "id": uuid, "client_token": string (30-min TTL), "url": string (hosted flow URL) } Example: curl -X POST https://idcheck.now/v1/verifications -H "X-Api-Key: $KEY" \ -H "Content-Type: application/json" \ -d '{"external_ref":"user_4821","user":{"email":"jane@example.com"},"redirect_url":"https://app.example/kyc/done"}' ### GET /v1/verifications — list (scope: verifications:read) Query: limit (1–200, default 50), offset (>=0, default 0), status (exact filter). Response: { "total": int, "limit": int, "offset": int, "items": [verification summary] } Summary: { id, external_ref, status, decision, score, created_at, expires_at } ### GET /v1/verifications/{verification_id} — retrieve (scope: verifications:read) Returns summary + decision_reasons, checks { doc_checks, face_match_score, liveness_score, license_fields }, consent { bipa, recording, policy_version, captured_at, ip, user_agent }, and events[] (ordered hash-chained audit log: seq, actor, action, payload, ip, event_hash, created_at). Another key's verification → 404 `verification not found`. license_fields: last_name, first_name, middle_name, date_of_birth, age, expiry, license_number, street, city, state, zip, sex. ### GET /v1/verifications/{verification_id}/media — presigned media links (scope: verifications:read) Response: { "media": { "": { "key", "sha256", "url"|null, "expires_in": 3600|null } } } Sides: front, back, selfie, selfie_frame_0..7. Bucket is private; URLs are SigV4 presigned, valid 3600 s; re-call for fresh links. Null URL in local-fallback (no S3) deployments. ### GET /v1/session/state (auth: X-Client-Token) Response: { id, verificationId, status, decision, score, expires_at, has_consent, has_front, has_back, has_selfie } ### POST /v1/session/consent (auth: X-Client-Token) Request: { "bipa": bool, "recording": bool, "policy_version": string } Both booleans must be true (422 `consent is required to proceed` otherwise). Response: { "status": "consented" }. Server records policy_version, timestamp, IP, user agent. ### POST /v1/session/document (auth: X-Client-Token) Request: { "side": "front"|"back", "image_b64": string|null, "barcode_raw": string|null, "barcode_parsed": object|null } - image_b64 required except back side with manually-pasted barcode_raw (weaker assurance; server re-decode skipped; doc_checks.no_server_image = true). - Back side: barcode_raw parsed server-side (AAMVA CDS v01–v12); 422 `barcode could not be parsed` on failure. With an image, server independently re-decodes and compares → doc_checks.server_client_barcode_match; mismatch → tamper_signal.barcode_mismatch. - Invalid base64 → 422 `invalid base64 image`. Response: { "status": "doc_front"|"doc_back", "sha256": string|null, "parsed": object|null, "checks": object (back only) } ### POST /v1/session/selfie (auth: X-Client-Token) Request: { "video_b64": string|null, "frames_b64": [string] } At least one required (422 `video_b64 or frames_b64 required`). First 8 frames stored. Runs face match (portrait vs selfie, 0.6), passive liveness (0.5), and 1:N duplicate-face search. Response: { "status": "selfie", "face_match_score": float|null, "liveness_score": float|null } ### POST /v1/session/submit (auth: X-Client-Token) Empty body. Only from status selfie or doc_back (409 `cannot submit from status ` otherwise). Runs OFAC screen on the document identity, applies face-dedupe impact, computes the decision, dispatches verification.decided webhooks, enrolls face embeddings. Synchronous. Response: { "status": "decided", "decision": "approve"|"review"|"decline", "score": number, "reasons": [string] } ### POST /v1/screen/ofac — OFAC SDN name screen (scope: screen:ofac) Request: { "name": string (2–255), "dob": "YYYY-MM-DD"|null } Response: { matched, confidence, score, query, entity?, matched_name?, name_type?, dob_corroborated?, reason, impact } - confidence: strong (score >= 0.95 individuals, >= 0.98 non-individuals) | possible (>= 0.85) | none. - impact: strong → "decline", possible → "review", none → null. - DOB corroboration: full DOB match upgrades possible→strong (dob_corroborated: true); full mismatch downgrades strong→possible (false); partial/year-only SDN data → no opinion (null). - reason: match | below_threshold | no_trgm_candidates | query_too_short. ### POST /v1/screen/wallet — OFAC SDN wallet screen (scope: screen:ofac) Request: { "address": string (8–255) } Exact (case-insensitive) match against SDN digital-currency addresses. Any hit → confidence strong, score 1.0, impact decline, reason `wallet_match:`. No hit → confidence none, reason no_wallet_match, impact null. ### GET /v1/screen/ofac/stats (scope: screen:ofac) Response: { "tables": { ofac_sdn_entities, ofac_sdn_names, ofac_sdn_wallets, ofac_sdn_dobs }, "last_refresh": { refreshed_at, entities, names, wallets, dobs, duration_seconds, source }|null } Data refreshed from live treasury.gov downloads (sdn.csv, alt.csv, sdn.xml). ### POST /v1/free/ofac — free OFAC lookup (NO AUTH, rate-limited 30/hour per IP) Request: { "name": string (2–200), "dob": "YYYY-MM-DD"|null } Response: { confidence, score, entity: { name, type, program }|null, matched_name, name_type, dob_corroborated, data_freshness } Headers: X-RateLimit-Limit: 30, X-RateLimit-Remaining. 429 + Retry-After when exhausted. Powers https://idcheck.now/tools/ofac-lookup. ### GET /v1/free/ofac/stats (NO AUTH) Response: { "tables": { ...counts }, "last_refresh": ISO string|null } ### POST /v1/keys — create API key (scope: keys:write) → 201 Request: { "label": string (1–255), "scopes": [string] } Response: { "id": int, "label", "scopes", "api_key": "idc_..." } — plaintext shown ONCE. ### GET /v1/keys — list keys (scope: keys:read) Response: [ { id, label, scopes, created_at, revoked_at, last_used_at } ] — never key material. ### POST /v1/keys/{key_id}/revoke (scope: keys:write) Sets revoked_at (idempotent), returns the public key object. 404 `api key not found`. ### POST /v1/webhooks — create endpoint (scope: webhooks:write) → 201 Request: { "url": https URL, "events": [string] ("*" = all) } Response: { id, url, events, secret, active } — secret shown ONCE (used to verify signatures). ### GET /v1/webhooks (scope: webhooks:read) → [ { id, url, events, active } ] (no secrets) ### DELETE /v1/webhooks/{endpoint_id} (scope: webhooks:write) → 204 (soft delete / deactivate) ### Webhook delivery format POST to your url, headers Content-Type: application/json + X-IdCheck-Signature (format above). Body: { "event": string, "verification_id": uuid, "data": object } Event catalog: - verification.decided — fired at submit. data: { decision, score, reasons } Retries: up to 3 attempts, backoff 0.5 s / 1.0 s / 2.0 s, 10 s timeout per attempt; status < 300 = delivered, else failed (then poll GET /v1/verifications/{id}). ### POST /v1/signals/collect — device/signal ingestion (NO AUTH) Request: { "visitor_id": string (1–64), "kind": "verify"|"marketing"|"admin", "path": string, "referrer": string|null, "components": object, "session_token": string|null } Caps: 64 KB body (413), ≤ 50 component keys, values truncated at 500 chars, 422 on bad JSON. With a valid client session_token, links to the verification and mirrors into doc_checks.device with derived bot_signals. Response: { "ok": true, "linked": bool, "bot_signals": { "webdriver": bool, "headless_hints": bool, "no_languages": bool, "incognito": bool } } ## Errors Envelope: { "detail": string } or, for 422 body validation, { "detail": [ {loc, msg, type} ] }. Status codes: - 400 bad request - 401 invalid API key | invalid or expired client token - 403 missing scope: - 404 verification not found | api key not found | webhook not found (also for cross-key access) - 409 cannot submit from status - 413 payload too large (signals collect, 64 KB) - 422 validation: invalid base64 image | consent is required to proceed | barcode could not be parsed | image_b64 required | video_b64 or frames_b64 required | field-level validation arrays - 429 free tier rate limit exceeded — 30 lookups per hour (Retry-After header, seconds) - 500 unexpected server error (retry with backoff) ## Rate limits & constraints - Free tier: 30 req/hour/IP, X-RateLimit-Limit/Remaining headers, 429 + Retry-After. - Keyed endpoints: no hard request-rate limit today (metered per completed verification; contact api@idcheck.now for bursts). - Client token TTL 1800 s; verification TTL 24 h; media presigned URL TTL 3600 s. - List page size: limit 1–200 (default 50). - Signals collect: 64 KB body, 50 component keys, 500-char values; max 8 selfie frames stored. - Webhooks: 3 attempts, 0.5/1.0/2.0 s backoff, 10 s timeout, success = status < 300. ## JavaScript SDK (@idcheck/web) npm: `npm install @idcheck/web` — or script tag: `` (global `window.IdCheck`). Zero dependencies, ~4 KB min, ESM + IIFE. Quickstart: import { createIdCheck } from '@idcheck/web'; const idcheck = createIdCheck({ publicKey: 'pk_test_...', sessionToken }); idcheck.open(); createIdCheck(config): - publicKey (required) — publishable key, e.g. pk_test_... - sessionToken OR createSessionToken: () => Promise (lazy, per open()) - baseUrl (default https://verify.idcheck.now) - mode: 'modal' (default) | 'inline' (needs container) | 'redirect' (uses redirectUrl; onComplete/onEvent do NOT fire across navigation — read result server-side) - theme: { primaryColor, colorScheme: 'dark'|'light' } - onEvent({type, payload}), onComplete({verificationId, status}), onExit(reason) Instance: open(), close(reason?), destroy(), isOpen. Events: ready | step | complete | error | exit. complete.status: approved | in_review | declined. Wire protocol: flow → host postMessage { source: 'idcheck', type, payload }, origin-validated against baseUrl; host → flow { source: 'idcheck-host', type: 'init', payload: { publicKey, theme } }. SECURITY: never trust onComplete alone — confirm via webhook or GET /v1/verifications/{id}. ## Quickstart (5 steps, curl) 1. Create a scoped key: POST /v1/keys (keys:write bootstrap key) → store api_key (shown once). 2. Create a verification: POST /v1/verifications → { id, client_token, url }. 3. Send the user to `url` (or SDK modal with client_token); they consent, capture license front/back + selfie, submit. 4. Receive verification.decided webhook (HMAC-signed) or poll GET /v1/verifications/{id}. 5. Read decision: approve (>=80) | review (50–79) | decline (<50) + decision_reasons.