Integration GuideAPI v5

FinAuth Integration Guide

Add FinAuth ID and face verification to your app or website. Your backend calls FinAuth; your app never holds a key.

For Bastion developers building mobile apps (Android, iOS, Capacitor/Ionic, React Native, Flutter) and websites.

Overview

The user photographs an ID, then looks at the screen while it flashes colours. FinAuth reads the ID, checks that a live person is present, and scores how closely the face matches the ID photo. Your backend makes the decision.

Rule 1: a backend is mandatory. Every FinAuth call that needs a key or a sign runs on your backend. Your app talks only to your backend.

Status

This guide describes a proof of concept. Test keys only. Read Known limits before you build on it.

The flow in four steps

  1. ID check. Your app sends an ID photo to your backend. Your backend sends it to AnyDoc, then accepts or rejects it by your own rules.
  2. Liveness session. Your backend calls raw/get_token with the accepted ID as image_ref1. Your app receives only the token and runs the RAW JS SDK with it.
  3. Result. The SDK reports 1000: the capture finished. Your backend reads raw/get_result.
  4. Decision. Your backend compares the confidence with the threshold from the same response, saves the verdict, and tells your app.

Sequence

User Your app Your backend FinAuth API FinAuth RTC WebView or page keys live here api-sgp rtc-sgp 1 ID photo 2 POST /kyc/id-check 3 AnyDoc, signed 4 cardType, fields 5 accept or reject 6 accepted or not each liveness attempt 7 start liveness 8 POST /kyc/liveness 9 raw/get_token + ID 10 token 11 token only 12 RtcVideo start: camera, colour flashes 13 resultCallback(1000): finished, not verified 14 GET …/result 15 raw/get_result, ≤ 3 16 result, thresholds 17 decide, persist 18 verdict 19 show it
Solid arrows are requests. Dashed arrows are responses. Steps 1 to 6 run once per ID. Steps 7 to 19 run for each liveness attempt, each with a new token. In step 13, 1000 means the capture finished, not that the user is verified. There is no FinAuth callback in this flow: your backend reads the result.

Choose a capture mode

This guide teaches RAW flash end to end. H5 lite and the Premium SDK are alternatives. Each has its own section.

ModeWhere it runsYour effortLook and feelRegionHow you get the result
RAW flash
(this guide)
Your own page, in a browser or an in-app WebView, with FinAuth's JS SDKMedium to high: your UI, WebRTC, stateYour branding. The screen flashes coloursSingapore onlyYour backend reads raw/get_result. No callback
H5 liteFinAuth's hosted page, in a browser or WebViewLowFinAuth's page, themed in the console. A desktop shows a QR code by defaultapi-global, labelled "Indonesia region" Conflict · OI-2Signed notify_url callback, then get_result
Premium SDKNative SDK inside your Android appHigh: SDK, model file, app approval, ARM onlyNative guided screensSingapore or IndonesiaYour backend calls verify. result_code is the verdict

Never use a product without liveness on its own to prove that a live person is present. Compare, OCR, AnyDoc and FaceSearch compare or read images. They do not check that the person is in front of the camera now.

Sources: Master §1, §4.1, §4.2, §6.1, §9.7, §10; REC-G1, REC-M1.

Before you start

You cannot call FinAuth until Finlaris has issued keys and, for the native SDK, approved your app.

Finlaris creates every key. Keys are not self-service (EC-3). Finlaris approves app IDs by hand (EC-5).

What you receive from Finlaris

  • A key and a secret for each product. The console calls a key an "Appkey". One key covers one product family. Finlaris's words: "Different API keys correspond to different endpoints" (EC-6). A key used on the wrong product gets a 403.
  • Scene IDs, for some products. A scene is a named set of console settings. The Premium SDK needs a liveness_id and a verify_id. H5 lite takes an optional scene_id. RAW flash and AnyDoc need no scene.
  • Console access. The console lists your keys, scenes and app approvals. The docs give the English login as http://global.yljz.com/console/login. Use https.
Warning

The secret is secret. Never put it in an app, a web page, git, logs, crash reports or analytics. Keep it in your backend's secret store. If it has been shipped or logged, treat it as leaked and ask Finlaris to rotate it.

A FinAuth key is long-lived and works account-wide for its product. Its calls are billed to you. Anyone who extracts it can call FinAuth as you, on your bill, with your customers' data.

What you send or configure

ItemExampleNotes
App ID for Mobile Client (Premium SDK only)com.example.kycMust equal your Android applicationId. The console shows "Under review", then "In use". Without it the SDK returns 1003 AUTHENTICATION_FAIL:{bundle_not_exist}. RAW flash and H5 lite need no app registration.
Signing-certificate SHA-256 (Premium SDK only)64 hex charactersThe vendor package ships a tool that prints it. The console may bind it with the app ID Inferred · OI-49. If so, debug and release builds need separate entries.
Products to enableAnyDoc, RAW flashAsk for each product you call. Each one has its own key. See Products, keys and scenes.
notify_url (H5 lite only)https://example.com/finauth/notifyYou send it in each get_token call. Ports 443, 5000, 16003, 8883 or 8028 only. https://example.com:8443/… will not work.
A contactname and emailFor key requests and app approvals. Keys come through your Finlaris account contact.

What is inside a sign

A sign is not opaque. Anyone can Base64-decode it and read the raw string, including your api_key. Only the first 20 bytes depend on the secret. Decode your own to debug: use the sign inspector. These are the parts of the test vector sign:

PartTest vector valueMeaning
MAC (bytes 1 to 20)afcf4cbb999de85d975a697bc7f7091672e5cfe9 (hex)HMAC-SHA1 of the raw string, keyed with the secret. 20 raw bytes.
atest-api-key-not-realThe api_key of the product you call. Readable by anyone.
b1800000100Expiry. Unix seconds. c + TTL.
c1800000000Now. Unix seconds.
d1234567892Random. Unsigned, at most 10 digits. New for every request.

Environments

  • Test keys only. Every key on the account is of type "Test" (EC-4). No production keys exist yet.
  • QPS 2 per key. A third request in the same second gets 403 CONCURRENCY_LIMIT_EXCEEDED. Production limits are not documented (OI-35).
  • Test quotas. When a test quota runs out, calls get 403 AUTHORIZATION_ERROR:LIMIT_REACHED.
  • Trial deduction. The AnyDoc page says billing "occurs (or trial usage is deducted)". Test calls likely draw down a trial allowance Inferred.

Sources: Master §3.1, §3.3, §3.4, §5.1, §5.2.1, §5.11, §9.6; EC-3…EC-6; OI-35, OI-49.

Hosts and request format

FinAuth runs regional hosts. Each product is available only on some of them. RAW flash and AnyDoc are on Singapore only.

Hosts

HostRegionUsed for in this guideNotes
https://api-sgp.yljz.comSingaporeAnyDoc, RAW flash get_token and get_result, Premium server APIsThe default host for this guide.
wss://rtc-sgp.yljz.comSingaporeRAW JS SDK WebRTC signallingPass it as host. Required since SDK 2.0.3. It is the only RTC host documented (OI-3).
https://api-global.yljz.comLabelled "Indonesia region" Conflict · OI-2H5 lite get_token, do, get_resultA "global" host labelled as Indonesia. Its physical region and data residency are unconfirmed.
https://api-idn.yljz.comIndonesiaPremium server APIs (alternative region)The H5 lite Access Guide puts the do page here, not on api-global Conflict · OI-2.
https://assets-global.yljz.com—RAW JS SDK download/raw-sdk/raw-jssdk-v2.0.3.tar.gz. Download it once and serve the file from your own origin.
https://api-mx.yljz.comMexico—Mexico INE only. Not covered.
https://api.yljz.comNot stated Conflict · OI-50—Hard-coded in the vendor's SDK demo apps only. Not in the docs' region list. Do not use it.

Transport

  • Every POST is multipart/form-data, including requests that carry no file.
  • H5 lite and RAW get_result are GET requests with query parameters.
  • HTTPS only.

Two auth styles

The product decides the auth style, not you.

StyleHowProducts in this guide
A. Raw key and secretSend api_key and api_secret as form fields, or as query parameters on get_result. No sign.RAW flash, H5 lite
B. HMAC signSend sign and sign_version=hmac_sha1. See How to sign a request.AnyDoc, Premium server APIs. Also Compare, OCR and the other server products.

Style A sends the secret itself on every call. That is one more reason to call FinAuth only from your backend.

Style A example: RAW flash token

POST https://api-sgp.yljz.com/finauth/lite/raw/get_token
Content-Type: multipart/form-data

api_key          = <FINAUTH_LIVENESS_KEY>       (from configuration)
api_secret       = <FINAUTH_LIVENESS_SECRET>    (from configuration)
image_ref1       = (file) id.jpg
comparison_type  = 0
procedure_type   = raw_flash
uuid             = <stable pseudonymous user ID>
…                  (full list in Liveness and face match)

Style B example: AnyDoc

POST https://api-sgp.yljz.com/finauth/v1/anydoc
Content-Type: multipart/form-data

sign          = <computed for this request from FINAUTH_ANYDOC_KEY and FINAUTH_ANYDOC_SECRET>
sign_version  = hmac_sha1
app_id        = builtin-laCUS0pQTRGOrEas0l
file          = (file) id.jpg

Responses and errors

  • request_id comes back on every call. Log it every time. FinAuth support needs it.
  • time_used is FinAuth's processing time, in milliseconds.
  • Your biz_no is echoed back where you sent one.

The error field's name depends on the product. Read both.

FieldProducts
errorPremium server APIs, Compare, OCR, and most others
error_messageH5 lite, RAW flash, AnyDoc

A 413 body is plain text (Request Entity Too Large), not JSON. Check the status before you parse.

Size limits

RequestLimitOver the limit
Face images (Premium, RAW get_token)4096 px400 INVALID_IMAGE_SIZE
Premium verify10 MB413, plain text
RAW flash get_token20 MB413, plain text
AnyDoc file9 MB. jpg, jpeg, png, gif, webp or PDF400 FILE_TOO_LARGE, FILE_UNSUPPORTED_FORMAT
OCR oversea_ocr image5 MB, 5000 × 5000 px400 INVALID_IMAGE_SIZE
RAW still raw/verify references (not covered)2 MB per image413
H5 lite get_tokenNot documented by FinAuth for this endpoint. The general limit is 10 MB.413
Warning

H5 lite and RAW get_result are GET requests with api_secret in the query string. Query strings end up in HTTP client logs, and likely in proxy and server access logs too Inferred. Call them only from your backend. Never log their URLs. A POC app logged this URL and wrote the secret to the device log (SEC-REV-17, DISC-34).

Sources: Master §3.2, §3.4, §5.0, §5.4, §5.5, §5.10, §5.11, §7.5, §17.1; OI-2, OI-3, OI-50.

How to sign a request

Auth style B products need a new sign on every request. Follow the steps in order. The example uses the test vector.

Steps

  1. Read the key and secret of the product you are calling.

    Each product has its own pair. A key sent with another key's secret gets 403 AUTHORIZATION_ERROR: INVALID_SIGN (TS-28).

    api_key    = test-api-key-not-real       (public test value)
    api_secret = test-api-secret-not-real    (public test value)
  2. c = now, in Unix seconds. b = c + TTL.

    The POC apps use a TTL of 100 seconds. Use seconds, not milliseconds. Date.now() gives 1800000000000. You need 1800000000.

    c = 1800000000
    b = 1800000100    (c + 100)
  3. d = a random unsigned integer of at most 10 digits.

    Take it from a cryptographic random generator. Make a new one for every request.

    d = 1234567892
  4. raw = a=<api_key>&b=<b>&c=<c>&d=<d>.

    This order. No spaces. No URL encoding. The order is fixed, not sorted.

    raw = a=test-api-key-not-real&b=1800000100&c=1800000000&d=1234567892    (62 characters)
  5. mac = HMAC-SHA1(key = UTF-8(secret), message = UTF-8(raw)).

    The result is 20 bytes. Keep the raw bytes, not the hex string. The hex is shown for debugging only.

    mac (hex) = afcf4cbb999de85d975a697bc7f7091672e5cfe9
  6. sign = standard Base64(mac ‖ UTF-8(raw)).

    MAC first, then the raw string. Use standard Base64 with +, / and = padding, not base64url. The length is 4 × ceil((20 + raw length) / 3). Here: 4 × ceil(82 / 3) = 112 characters.

    sign = r89Mu5md6F2XWml7x/cJFnLlz+lhPXRlc3QtYXBpLWtleS1ub3QtcmVhbCZiPTE4MDAwMDAxMDAmYz0xODAwMDAwMDAwJmQ9MTIzNDU2Nzg5Mg==
  7. Send sign and sign_version=hmac_sha1 with the request's other fields as multipart/form-data.

    Send sign as it is. A multipart field needs no URL encoding.

Tip

Prove your helper against the test vector before you call FinAuth.

Anatomy of a sign

bytes HMAC-SHA1 20 bytes raw = a=…&b=…&c=…&d=… 62 bytes in the test vector keyed with the secret readable by anyone 20 + 62 = 82 bytes standard Base64: keeps + / and = padding sign: 4 × ceil(82 / 3) = 112 characters, ends with ==
The MAC comes first. Only the MAC depends on the secret. The raw string, including your api_key, travels in clear inside the Base64.

Notes

  • Single use. The Premium authentication page allows b = 0 for a single-use sign. Other pages do not mention it and say a sign can be reused until it expires Conflict. Use a short TTL instead.
  • Wording of c. One page says c "must be smaller than expire_time"; another says "not older than" Conflict · OI-38. c = now and b = now + 100 satisfies both.
  • Clock. FinAuth's tolerance for clock skew is not documented. A wrong server clock likely shows up as EXPIRED_SIGN Inferred. Keep NTP on (GL-7).
  • Vendor samples. Do not copy them. The Python sample is Python 2. The Objective-C sample formats current_time with %f and has stray spaces. The Java sample treats expiry as a duration. The vendor's Android demo signs on the device with a non-cryptographic random number.

Sources: Master §3.4, §5.1, §5.2.2, §13 #33, §13 #47; TS-10, TS-28; OI-38.

Test vectors

Use these to prove your code before you call FinAuth. HMAC-SHA1 and SHA-1 are deterministic, so a correct implementation reproduces each value exactly.

The values were computed on 2026-11-04 by running the TypeScript reference with Node crypto, and cross-checked with OpenSSL. If your result differs, your code is wrong. Do not change the vector.

Card A: request sign

Request sign (auth style B)
api_key (public test value)test-api-key-not-real
api_secret (public test value)test-api-secret-not-real
c (current_time)1800000000
b (expire_time)1800000100
d (random)1234567892
raw (62 characters)a=test-api-key-not-real&b=1800000100&c=1800000000&d=1234567892
HMAC-SHA1, hex (20 bytes, for debugging only)afcf4cbb999de85d975a697bc7f7091672e5cfe9
Expected sign (112 characters)r89Mu5md6F2XWml7x/cJFnLlz+lhPXRlc3QtYXBpLWtleS1ub3QtcmVhbCZiPTE4MDAwMDAxMDAmYz0xODAwMDAwMDAwJmQ9MTIzNDU2Nzg5Mg==
These are public test values. They are never real credentials. d was chosen so that the sign contains +, / and ==. That catches URL-safe Base64 and stripped padding.

Check in this order:

  1. Your raw string is exactly 62 characters and equals the one above.
  2. Your MAC hex equals the one above.
  3. Your sign is 112 characters and equals the expected sign, character for character.

If raw differs, the MAC and the sign will differ too. Fix raw first. Find the first differing character. Typical causes are spaces, a different field order, URL-encoded values, or milliseconds.

If raw matches and the sign does not, the sign itself shows the mistake:

Your signCause
136 charactersYou Base64-encoded the 40-character hex digest instead of the 20 raw bytes.
84 charactersYou left out the MAC and encoded only the raw string.
Contains - or _, or has no trailing ==URL-safe Base64, or stripped padding. The URL-safe, unpadded form of this vector is 110 characters.
112 characters, starts with YT10Raw first, MAC second. Put the MAC first.
112 characters but differentWrong secret, wrong key, or a different raw string.

The TypeScript reference reproduces the vector:

import { signWith, verifyCallbackSign } from './finauth-sign.js';

const sign = signWith('test-api-key-not-real', 'test-api-secret-not-real', 1800000100, 1800000000, 1234567892);
console.log(sign.length);   // 112
console.log(sign);          // r89Mu5md6F2XWml7x/cJFnLlz+lhPXRlc3QtYXBpLWtleS1ub3QtcmVhbCZiPTE4MDAwMDAxMDAmYz0xODAwMDAwMDAwJmQ9MTIzNDU2Nzg5Mg==

const data = '{"status":"OK","fail_reason":null,"biz_info":{"biz_id":"test-biz-id-0001","biz_no":"test-biz-no-0001","biz_extra_data":"https://example.com/kyc?step=1&lang=en"}}';
console.log(verifyCallbackSign('test-api-secret-not-real', data, '116cb19ca5db0b25428e6064e86c3518bedac8ce'));   // true

Sign inspector

Decode and check a sign

    Runs only in your browser. Makes no network calls. A sign contains your api_key in readable form, but not your secret. There is no field for a secret. Do not paste one anywhere on this page.

    Card B: H5 lite callback sign

    A shortened example, not a real callback.

    Callback sign (H5 lite notify_url)
    api_secret (public test value)test-api-secret-not-real
    data (161 characters, exactly as received){"status":"OK","fail_reason":null,"biz_info":{"biz_id":"test-biz-id-0001","biz_no":"test-biz-no-0001","biz_extra_data":"https://example.com/kyc?step=1&lang=en"}}
    Expected sign = sha1(secret + data), lowercase hex116cb19ca5db0b25428e6064e86c3518bedac8ce
    The secret is placed directly before data, with nothing in between. This is plain SHA-1, not HMAC.

    Hash the data form field exactly as received, before you parse it. Re-serializing changes the bytes. For example, .NET System.Text.Json escapes & as & by default. A pretty-printer adds spaces and line breaks. Either breaks the match:

    What you hashsha1(secret + it)Matches?
    data as received (161 characters)116cb19ca5db0b25428e6064e86c3518bedac8ceYes
    & escaped as & (166 characters)41c33a6c24b7b6490baf659bfa91b04281754e6bNo
    Pretty-printed with 2-space indent8a88b955d6d827549f9a0ce474a8b5dd2f46488fNo

    Callback sign checker

    Check a callback sign with the public test secret

      Works only with the public test secret, by design. There is no field for a real secret. Runs only in your browser and makes no network calls.

      Sources: Master §3.4, §5.1, §6.6, EP-9. Vectors computed in this run (Node 22, OpenSSL).

      Reference code

      Copy and adapt, not a dependency

      There is no Bastion SDK. The TypeScript is the reference. It reproduces both test vectors (run with Node 22 on 2026-11-04). The C# and Python versions are translations that have not been run. Run them against the test vectors before you rely on them.

      Signing and callback helpers

      Node 18 or later. Uses only node:crypto. timingSafeEqual compares in constant time.

      // Reference. Run against the test vectors. Node 18+, no packages. Backend only.
      import { createHash, createHmac, randomInt, timingSafeEqual } from 'node:crypto';
      
      // Auth style B. Builds sign from fixed values, so you can test it against the vector.
      export function signWith(apiKey: string, apiSecret: string, expire: number, now: number, random: number): string {
        const raw = `a=${apiKey}&b=${expire}&c=${now}&d=${random}`;        // this order, no spaces, no URL encoding
        const mac = createHmac('sha1', Buffer.from(apiSecret, 'utf8'))      // key = UTF-8 bytes of the secret
          .update(raw, 'utf8')
          .digest();                                                          // 20 raw bytes, not hex
        return Buffer.concat([mac, Buffer.from(raw, 'utf8')]).toString('base64');   // standard Base64: keeps + / =
      }
      
      // Auth style B. A new sign for every request.
      export function finauthSign(apiKey: string, apiSecret: string, ttlSeconds = 100): string {
        const now = Math.floor(Date.now() / 1000);                            // seconds, not milliseconds
        const random = randomInt(0, 10_000_000_000);                         // at most 10 digits, cryptographic RNG
        return signWith(apiKey, apiSecret, now + ttlSeconds, now, random);
      }
      
      // H5 lite notify_url. sign = sha1(api_secret + data), lowercase hex. Pass data exactly as received.
      export function verifyCallbackSign(apiSecret: string, rawData: string, sign: string): boolean {
        const expected = Buffer.from(createHash('sha1').update(apiSecret + rawData, 'utf8').digest('hex'), 'utf8');
        const given = Buffer.from(sign, 'utf8');
        return expected.length === given.length && timingSafeEqual(expected, given);
      }

      Reference backend shape

      Suggested shape (REC-G1)

      This is not an existing Bastion API. It is the smallest backend that keeps every key off the client.

      EndpointWhat it doesReturns to the app
      POST /kyc/id-checkTakes the ID image. Calls AnyDoc. Applies your acceptance rule. Stores the accepted image server-side with the session.Accepted or not, cardType, the fields you show. Never the raw AnyDoc response.
      POST /kyc/livenessLoads the accepted ID image for this session. Calls raw/get_token with it as image_ref1.The token only.
      GET /kyc/liveness/{id}/resultCalls raw/get_result (at most 3 reads). Persists the first finished result. Runs the decision.The verdict: verified, not matched, liveness failed, or couldn't finish.

      The app never sends the reference image for liveness. Your backend reuses the image it already checked. A modified client therefore cannot swap in a different face for the comparison.

      A runnable POC of these routes exists separately.

      The FinAuth calls and the decision function. Your HTTP framework, storage and sessions go around them.

      // Copy and adapt. Suggested shape (REC-G1), not an existing Bastion API. Not run against FinAuth.
      // Node 18+: global fetch, FormData and Blob. Keys come from your secret store, one pair per product.
      import { finauthSign } from './finauth-sign.js';
      
      export type Keys = { apiKey: string; apiSecret: string };
      export type Level = '1e-4' | '1e-5';
      export type Outcome = 'verified' | 'not-matched' | 'liveness-failed' | 'couldnt-finish';
      
      const SGP = 'https://api-sgp.yljz.com';
      const PH_IDS_APP_ID = 'builtin-laCUS0pQTRGOrEas0l';
      
      export class FinAuthError extends Error {
        httpStatus?: number;
        error?: string;
        constructor(message: string, httpStatus?: number, error?: string, cause?: unknown) {
          super(message, { cause });
          this.httpStatus = httpStatus;
          this.error = error;
        }
      }
      
      // Every FinAuth call goes through here. Never log `url`: for get_result its query holds api_secret.
      async function call(path: string, init: RequestInit, query?: URLSearchParams): Promise<any> {
        const url = SGP + path + (query ? `?${query}` : '');
        let res: Response;
        let body: string;
        try {
          res = await fetch(url, { ...init, signal: AbortSignal.timeout(15_000) });
          body = await res.text();
        } catch (e) {
          throw new FinAuthError(`FinAuth unreachable: ${path}`, undefined, undefined, e);
        }
        if (res.status === 413) {
          throw new FinAuthError(`FinAuth 413 on ${path}: file too large`, 413, body.trim());   // plain text, not JSON
        }
        let json: any;
        try {
          json = JSON.parse(body);
        } catch (e) {
          throw new FinAuthError(`FinAuth ${res.status} on ${path}: body is not JSON`, res.status, undefined, e);
        }
        const error: string | undefined = json.error ?? json.error_message ?? undefined;     // name varies by product
        if (!res.ok) {
          throw new FinAuthError(`FinAuth ${res.status} on ${path}: ${error} (request_id ${json.request_id})`, res.status, error);
        }
        return json;
      }
      
      // 1. ID document check. Auth style B (sign). Key: the AnyDoc key.
      export function anyDoc(keys: Keys, idImage: Blob): Promise<any> {
        const form = new FormData();
        form.set('sign', finauthSign(keys.apiKey, keys.apiSecret));
        form.set('sign_version', 'hmac_sha1');
        form.set('app_id', PH_IDS_APP_ID);
        form.set('file', idImage, 'id.jpg');
        return call('/finauth/v1/anydoc', { method: 'POST', body: form });
      }
      
      // 2. Liveness token. Auth style A (raw key and secret). Key: "liveness detection and deepfake detection".
      export function rawGetToken(keys: Keys, idImage: Blob, ids: { bizNo: string; uuid: string }): Promise<any> {
        const form = new FormData();
        form.set('api_key', keys.apiKey);
        form.set('api_secret', keys.apiSecret);
        form.set('image_ref1', idImage, 'id.jpg');         // exactly this name: the ID image you accepted
        form.set('comparison_type', '0');                  // the only value: RAW flash always compares
        form.set('procedure_type', 'raw_flash');
        form.set('color_number', '6');
        form.set('max_upload_times', '5');
        form.set('life_time', '60');                       // minutes
        form.set('fail_when_ref_multiple_faces', '0');     // ID cards can carry a second, smaller portrait
        form.set('uuid', ids.uuid);                        // stable pseudonymous user ID, not random per session
        form.set('biz_no', ids.bizNo);                     // yours, unique per attempt
        return call('/finauth/lite/raw/get_token', { method: 'POST', body: form });
      }
      
      // 3. Result. A GET with the secret in the query string: backend only, never log the URL.
      export function rawGetResult(keys: Keys, token: string): Promise<any> {
        const query = new URLSearchParams({ api_key: keys.apiKey, api_secret: keys.apiSecret, token, return_image: '0' });
        return call('/finauth/lite/raw/get_result', { method: 'GET' }, query);
      }
      
      // Polling: suggested, not tested (OI-56). Never more than 3 reads per token.
      export async function readResult(keys: Keys, token: string): Promise<any> {
        let last: any = null;
        for (let read = 1; read <= 3; read++) {
          try {
            last = await rawGetResult(keys, token);
          } catch (e) {
            throw new FinAuthError(`raw/get_result read ${read} of 3 failed`, undefined, undefined, e);
          }
          const status = String(last.status ?? '').toUpperCase();
          if (status !== 'NOT_STARTED' && status !== 'PROCESSING') return last;   // persist it before you decide
          if (read < 3) await new Promise((done) => setTimeout(done, 3000));
        }
        return last;                                                               // still not finished after 3 reads
      }
      
      // Default deny (REC-G3). The threshold comes from the same response. Never hard-code a number.
      // Log liveness_result.score.flash_attack and living_attack. Do not decide on them: no thresholds exist.
      export function decide(r: any, level: Level): { outcome: Outcome; reason: string } {
        const up = (x: unknown) => String(x ?? '').toUpperCase();                 // the docs mix "Failed" and "FAILED"
        const num = (x: unknown) => (typeof x === 'number' ? x : NaN);
        if (up(r?.status) !== 'OK') return { outcome: 'couldnt-finish', reason: `status ${r?.status}` };
        if (up(r.liveness_result?.result) !== 'PASS') {
          return { outcome: 'liveness-failed', reason: `liveness ${r.liveness_result?.result}` };
        }
        const v = r.verify_result;
        if (v?.error_message) return { outcome: 'couldnt-finish', reason: `verify ${v.error_message}` };
        const confidence = num(v?.result_ref1?.confidence);
        const threshold = num(v?.result_ref1?.thresholds?.[level]);
        if (!Number.isFinite(confidence) || !Number.isFinite(threshold)) {
          return { outcome: 'couldnt-finish', reason: 'no confidence or threshold' };   // never assume a match
        }
        return confidence > threshold
          ? { outcome: 'verified', reason: `${confidence} > ${threshold} at ${level}` }
          : { outcome: 'not-matched', reason: `${confidence} <= ${threshold} at ${level}` };
      }

      Configuration

      Load keys from environment variables or a vault, never from source files. Keep one pair per product and one per environment (REC-G2). For example: FINAUTH_ANYDOC_KEY, FINAUTH_ANYDOC_SECRET, FINAUTH_LIVENESS_KEY, FINAUTH_LIVENESS_SECRET, each with separate test and production values. In .NET, use user secrets in development and your vault in production, bound to FinAuthOptions. Never print these values in logs, errors or support tickets.

      Sources: Master §5.1, §5.5, §5.11, §6.6, §18.2 (REC-M1…M6), §18.3 (REC-G1…G4).

      ID document check (AnyDoc)

      AnyDoc reads a document image and returns its text fields. For Philippine IDs, Finlaris recommends AnyDoc with one specific app_id (EC-26). The key is the AnyDoc key: its console name is not in the sources (SEC-9 / SEC-10 Inferred).

      Request

      POST https://api-sgp.yljz.com/finauth/v1/anydoc      (Singapore only, auth style B)
      FieldRequiredValue to useNotes
      signYescomputed per requestSigned with the AnyDoc key. See How to sign a request.
      sign_versionYeshmac_sha1
      fileYesthe ID imageAt most 9 MB. jpg, jpeg, png, gif, webp or PDF.
      app_idYesbuiltin-laCUS0pQTRGOrEas0l13 Philippine document types. An unknown app_id gets 400 BAD_ARGUMENTS.
      inputsNoleave it outA JSON string. No app_id defines any inputs.

      Apply the photo's EXIF rotation before you send it. An upload stored sideways can lower the quality of the reading and of the later face match Inferred (D-APP2-9).

      Response

      The shape seen in testing, values left out (EC-27). On failure, error_message is set.

      {
        "request_id": "…",
        "time_used": …,
        "result": {
          "pages": 1,
          "outputs": {
            "text": {
              "cardType": "NATIONAL_ID",
              "no": "…", "givenName": "…", "middleName": "…", "surname": "…",
              "gender": "…", "dob": "…", "pob": "…", "blood": "…",
              "marital": "…", "address": "…", "issDate": "…"
            }
          }
        }
      }

      The 13 types: UMID, SSS_ID, PASSPORT, DriverLicense, NATIONAL_ID, PostalID, PRCID, TIN_ID, VOTER_ID, OWWA_ID, GSIS_ID, PHILHEALTH_ID, PAGIBIG_ID.

      The docs spell three of them two ways: DriverLicense or DRIVER_LICENSE, PostalID or POSTAL_ID, PRCID or PRC_ID Conflict · Q-29. Accept both spellings until FinAuth confirms one.

      Field names differ by document type. The fields above came back for a NATIONAL_ID result. FinAuth's AnyDoc page lists fields per type; those lists are not in this guide's sources.

      Billing

      AnyDoc bills per page: pages × unit price. It bills only when HTTP is 200 and pages is not empty. Errors are not billed. Unit prices are not documented (OI-44).

      Example: a school student ID sent with the Philippine app_id came back with HTTP 200 and pages 1. It was billable, and it is not a supported ID (EC-27).

      Acceptance rule

      Accept an ID only if both are true (REC-M5):

      • cardType is in your list of accepted types.
      • Every field you require for that type is present and not empty.

      A blank error_message is not enough. AnyDoc puts any document into one of its 13 types. In testing, a school student ID came back as cardType NATIONAL_ID. The name and the school number landed in surname, givenName and no. The other fields were empty (EC-27). The team accepted this behaviour for now (EC-30), so your rule has to catch it.

      // Copy and adapt (REC-M5). A blank error_message is not enough: AnyDoc puts any document into one of its types.
      // Example policy only. List the types you accept and the fields each one must have.
      const ACCEPTED: Record<string, string[]> = {
        NATIONAL_ID: ['no', 'surname', 'givenName', 'dob'],
        // If you accept driver's licences, list both spellings until FinAuth confirms one (Q-29):
        // DRIVER_LICENSE: [...], DriverLicense: [...],
      };
      
      export function acceptId(r: any): { accepted: boolean; cardType: string; missing: string[] } {
        const text = r?.result?.outputs?.text ?? {};
        const cardType = String(text.cardType ?? '');
        const required = ACCEPTED[cardType];
        if (!required) return { accepted: false, cardType, missing: [] };          // type not on your list
        const missing = required.filter((field) => !String(text[field] ?? '').trim());
        return { accepted: missing.length === 0, cardType, missing };
      }

      This example rejects that student-ID response, because dob was empty.

      Alternative: ID OCR

      oversea_ocr with certificate_id=PH_ID reads two Philippine IDs: PhilSys National ID and PhilHealth ID. UMID, Pag-IBIG and LTO driver's licence failed in testing (EC-25). It uses a different key, "ID OCR for Philippines" (SEC-1 / SEC-2), on api-sgp or api-idn.

      Its risk flags (screen, ps, aigc, watermark, photocopy, sticker, quality) are "0" for OK, "1" for an issue, or null when the check was unavailable. Treat null as "unverified", never as "pass".

      Sources: Master §5.10, §5.11, §6.9, §9.7, §13 #53; EC-25…EC-27, EC-30; REC-M5, REC-P7.

      Liveness and face match (RAW flash)

      RAW flash runs FinAuth's JavaScript SDK in your own page. The screen flashes a sequence of colours, and FinAuth analyses the reflections on the face. It then compares the face with image_ref1. Comparison is mandatory: there is no liveness-only RAW flash (EC-18). RAW flash runs in Singapore only.

      The key is "liveness detection and deepfake detection" (SEC-3 / SEC-4). Finlaris confirmed it for RAW flash, and a web test with it worked end to end (EC-16, EC-17).

      Get a token: raw/get_token

      POST https://api-sgp.yljz.com/finauth/lite/raw/get_token      (auth style A, backend only)
      ParameterRequiredValue to useNotes
      api_key, api_secretYesthe liveness key pairFrom configuration, on your backend.
      image_ref1Yesthe accepted ID imageThe first image field must be named exactly image_ref1. Otherwise: 400 MISSING_ARGUMENTS:image_ref1 (TS-18).
      comparison_typeYes0The only value. Others get 400. A key without permission gets 403 Denied.
      procedure_typeNoraw_flashThe only value.
      color_numberNo64, 6 or 8. 6 is recommended.
      max_upload_timesYes52 to 5. 5 is recommended.
      life_timeYes60Minutes, 5 to 1440. Also governs how long the result can be queried. The token's stated "default validity 1 h" contradicts it Conflict · OI-4.
      uuidYesa stable pseudonymous user IDUp to 512 bytes. Not random per session (DISC-16). If you leave it blank, FinAuth makes one up and you lose the link to your user.
      biz_noNoyours, unique per attemptUp to 128 bytes. Echoed back. Log it.
      fail_when_ref_multiple_facesNo0The default 1 fails with MULTIPLE_FACES when the reference holds more than one face. 0 uses the largest face. Philippine IDs can carry a second, smaller portrait Inferred (DISC-31).
      languageNoleave it out0 English (default), 1 Chinese.
      liveness_preferencesNoleave it outnone is the only value and the default.
      multi_oriented_detection, return_multifaces_tag, face_occlusion_detectionNoleave them out0/1 switches.

      The response holds request_id, biz_no, time_used, token (up to 1024 bytes) and error_message. get_token errors are not billed.

      A wrong key or secret returns 403 AUTHENTICATION_ERROR with the text "Invalid signature", even though RAW flash uses no sign. Check the key pair.

      Run the capture: RAW JS SDK

      • Download https://assets-global.yljz.com/raw-sdk/raw-jssdk-v2.0.3.tar.gz. It holds dist/rtcsdk.min.js (UMD), dist/esm/rtcsdk.js (ESM), an HTML demo and a React demo.
      • Version 2.0.3 (2025-08-22) is current.
      • The class is RtcVideo. The docs' sample says RctVideo, a name dropped in 2.0.0 Conflict.
      • host: 'wss://rtc-sgp.yljz.com' is required. Since 2.0.3 there is no default. Without it the SDK calls localhost/undefined/raw_signal and fails with 4000 NETWORK_ERROR (TS-17).
      • videoWrapper is an element id string, not a DOM element. The SDK looks the element up itself Inferred.
      OptionWhat it isValue to use
      videoWrapperId of the element that holds the video'liveness-video'
      hostWebRTC signalling host'wss://rtc-sgp.yljz.com'
      tokenFrom your backendNever call get_token from the page
      width, heightVideo size'100%', 'auto'
      videoPermsTimeout, webSocketTimeout, WebRTCTimeoutTimeouts, in ms6000 each (as in the POCs)
      colorListener(color, status, index, sum)The current flash colour. status is RESET, DONE or COLORINGPaint the page background with color
      tipListener(tipCode, tipTxt)Guidance for the userMap tipCode to your own text (below)
      statusListener(code)Status changesUpdate your screen
      resultCallback(isSuccess, resultCode, resultMessage)End of the captureOn 1000, ask your backend for the result
      autoplayFailCallback(play)The browser blocked autoplayShow a button that calls play() Inferred

      Methods: start(), destroy() and version(). After destroy(), remove the video element yourself.

      // Your page, in a browser or a WebView. Copy and adapt. Not tested as written.
      // Load dist/rtcsdk.min.js (RAW JS SDK 2.0.3) from your own origin first. The page never holds a key.
      async function startLiveness(sessionId) {
        let token;
        try {
          const res = await fetch('/kyc/liveness', {                // your backend, not FinAuth
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ sessionId }),
          });
          if (!res.ok) throw new Error(`backend returned ${res.status}`);
          ({ token } = await res.json());
        } catch (e) {
          showState('couldnt-finish', 'start-failed');
          console.error('Could not get a liveness token', e);
          return;
        }
      
        const rtc = new RtcVideo({
          videoWrapper: 'liveness-video',          // an element id string, not an element
          host: 'wss://rtc-sgp.yljz.com',          // required since 2.0.3: there is no default
          token,
          width: '100%',
          height: 'auto',
          videoPermsTimeout: 6000,                 // ms
          webSocketTimeout: 6000,
          WebRTCTimeout: 6000,
          colorListener: (color) => { document.body.style.backgroundColor = color; },
          tipListener: (tipCode) => showTip(TIP_TEXT[tipCode] ?? ''),
          statusListener: (code) => showStatus(code),
          autoplayFailCallback: (play) => showTapToStart(play),   // call play() from a tap (inferred)
          resultCallback: (isSuccess, resultCode, resultMessage) => {
            rtc.destroy();
            document.getElementById('liveness-video').replaceChildren();   // destroy() leaves the video element
            document.body.style.backgroundColor = '';
            if (Number(resultCode) === 1000) showState('checking-result');   // finished, not verified
            else showState('couldnt-finish', `${resultCode} ${resultMessage}`);
          },
        });
        rtc.start();
      }

      On screen the capture runs LOOK_MIRROR (the user frames their face and tips appear), then SHOW_COLOR (the colours flash), then RESULT_LOADING, then resultCallback.

      Status codes (statusListener)

      CodeMeaning
      INIT_LOADINGStarting up.
      INIT_LOADING_OVERStarted.
      LOOK_MIRRORThe user frames their face. Tips appear.
      SHOW_COLORThe screen flashes colours.
      RESULT_LOADINGSending the capture.
      RESULT_LOADING_OVERSent.
      DISCONNECTEDThe connection dropped. A refresh is needed.

      FinAuth lists the names and the order. The meanings of the loading codes are read from their names Inferred.

      Tip codes (tipListener)

      FinAuth lists only the names. The meanings and the suggested text are read from the names Inferred. Show the text live, one tip at a time.

      tipCodeLikely meaningShow the user
      NoFaceFoundNo face in view"Put your face in the frame."
      InvalidFaceYawHead turned left or right"Look straight at the screen."
      InvalidFacePitchHead tilted up or down"Hold your phone at eye level."
      InvalidFaceEyeOcclusionEyes covered"Make sure we can see your eyes. Take off sunglasses."
      InvalidFaceMouthMouth covered"Uncover your mouth."
      TooFarAwayFromCameraToo far"Move closer."
      TooCloseFromCameraToo close"Move back a little."
      InvalidFaceOutOfImageFace partly out of frame"Keep your whole face in the frame."
      InvalidFaceBrightnessToo dark or too bright"Face a light. Avoid bright light behind you."
      FaceOKGood position"Hold still."

      Result codes (resultCallback)

      CodeMessageMeaningWhat you do
      1000SUCCESSThe capture finished. Not verified.Show "Checking your result…". Ask your backend.
      2000DISCONNECTEDThe connection dropped.Get a new token and retry.
      3000TIMEOUT30 seconds passed after connecting.Retry with a new token.
      4000SUPPORT_ERROR, PERMISSIONS_ERROR, NETWORK_ERRORBrowser not supported, camera denied, or no data for 6 seconds.Set host. Grant the camera. Check the network. Offer another browser.
      5000TOKEN_EXPIRED, TOKEN_TIMES_EXCEEDED, ILLEGAL_PARAMETER:{params}Token problem, or a bad option.Get a new token. Fix the options.

      A POC app reported its own JavaScript errors as 5000 JS_ERROR, which looks like a FinAuth token error (DISC-38). Give your own errors a code outside FinAuth's range, such as 9001.

      Read the result: raw/get_result

      GET https://api-sgp.yljz.com/finauth/lite/raw/get_result?api_key=…&api_secret=…&token=…&return_image=0
          (auth style A, backend only, never log this URL)
      ParameterValue to useNotes
      api_key, api_secretthe liveness key pairIn the query string.
      tokenfrom raw/get_token
      return_image01 and 2 return image_best (and image_best_2). The difference between 1 and 2 is not documented by FinAuth. Ask for images only if your retention policy covers them.
      Response fieldMeaning
      statusNOT_STARTED, PROCESSING, OK or FAILED. OK means finished, not passed. FAILED means the 30-second framing step failed.
      liveness_result.resultPASS or FAIL.
      liveness_result.score.flash_attack, living_attackAttack scores. No thresholds are documented (OI-20). Log them. Do not decide on them.
      verify_result.error_messagenull when the comparison ran. Otherwise NO_FACE_FOUND, DATA_SOURCE_ERROR (stop and contact FinAuth) or INTERNAL_ERROR (retry later).
      verify_result.result_ref1.confidenceSimilarity, 0 to 100. Not a percentage chance.
      verify_result.result_ref1.thresholds"1e-3", "1e-4", "1e-5", "1e-6": the confidence needed for each false-accept rate. 1e-4 means 1 wrong person accepted in 10,000 tries. They change on every call.
      image_best, image_best_2Only if return_image asks for them.

      The docs' sample responses are invalid JSON and contradict the field list Conflict. Code against the field list.

      • Results are kept for 1 day.
      • At most 3 reads per token. A fourth read gets 403 DATA_DESTROYED.
      • RAW flash has no callback. Your backend must read the result.
      • Billing for RAW flash is not documented by FinAuth (OI-17).

      Polling

      Suggested, not tested (OI-56)

      Whether get_result can still say PROCESSING right after 1000 is not documented. These steps stay inside the 3-read limit either way.

      1. Read once, right after resultCallback(1000).
      2. While status is NOT_STARTED or PROCESSING, wait about 3 seconds and read again.
      3. Never read more than 3 times. After the third read, show "Couldn't finish".
      4. Persist the first finished result before you decide.

      Example: the SDK returns 1000 at 10:00:00. Read 1 at 10:00:00 says PROCESSING. Read 2 at 10:00:03 says OK. Persist it, then decide. One read is left for recovery.

      Decide

      Default deny (REC-G3). Check in this order. The first failed check decides.

      1. status is OK. Otherwise: couldn't finish.
      2. liveness_result.result is PASS. Otherwise: liveness failed.
      3. verify_result.error_message is null. Otherwise: couldn't finish.
      4. result_ref1.confidence > result_ref1.thresholds[level], both from the same response. Otherwise: not matched.
      5. Anything missing or unknown: deny.

      Level. Use "1e-4" (regular) for ordinary checks. Use "1e-5" for high-risk actions such as loans, transfers and payouts (REC-G4). The level per use case is still an open decision (OI-26).

      Never hard-code a number such as 75. Thresholds change per call. The 1e-3 threshold was 62.168 in one test call and 65.3 in a docs sample. A fixed 75 can be looser than the level you meant on some calls (RISK-10). Two POCs did this (DISC-9, DISC-36).

      The code is decide() in the reference backend.

      Sources: Master §5.5 (EP-10…EP-12), §6.4, §7.2, §7.3, §9.7, §12 (DISC-16, DISC-31, DISC-36, DISC-38), §13 #4, #26, #27, #54; EC-16…EC-18; OI-4, OI-17, OI-20, OI-26, OI-56; TS-17, TS-18.

      H5 lite

      H5 lite is a capture page hosted by FinAuth. Your backend gets a token. The user's browser opens FinAuth's page. When the capture ends, FinAuth calls your server and sends the browser back to you.

      The key for H5 lite is not identified in the sources (OI-33). Ask Finlaris which key covers it.

      When to choose it

      • Mobile-web self-service, when delivery speed matters more than branding. You write no camera code.
      • When you want a signed server callback instead of polling.
      • When some of your users' browsers lack WebRTC. H5 lite can fall back to a short still video.

      Choose RAW flash for your own branded UI. Choose the Premium SDK for a native app. On a desktop, H5 lite shows a QR code by default and continues on a phone (EC-22).

      Sequence

      User's browser Your backend FinAuth H5 lite keys live here api-global 1 start verification 2 lite/get_token 3 token, biz_id 4 store biz_no, biz_id 5 redirect to lite/do?token=… 6 open FinAuth's capture page 7 return_url: not signed, for the screen only 8 notify_url: data + sign 9 verify sign, dedupe 10 lite/get_result?biz_id=… 11 result, thresholds 12 decide, persist 13 verdict
      Solid arrows are requests. Dashed arrows are responses and redirects. Step 7 passes through the user's browser and is not signed: use it only to change the screen. Step 8 is server to server and signed. Decide only after step 11. Steps 7 and 8 can arrive in either order.

      Get a token: get_token

      POST https://api-global.yljz.com/finauth/lite/get_token      (auth style A, backend only)
      ParameterValue to useWhy
      api_key, api_secretthe H5 lite key pairBackend only.
      fmp_mode0The default 1 gives no anti-spoof verdict: PASS then does not mean "not a spoof" (DISC-12). Always send 0.
      comparison_type00 = liveness and comparison with image_ref1. -1 = liveness only. This flow compares with the accepted ID.
      image_ref1the accepted ID imageRequired when comparison_type is 0.
      fail_when_ref_multiple_faces0Uses the largest face. ID cards can carry a second portrait Inferred.
      procedure_typeflashThe default. Also active_flash, distance and still.
      procedure_prioritystillFalls back to a still video when the browser has no WebRTC. Already the default for flash.
      language4 for Filipino0 English (default), 1 Chinese, 2 Indonesian, 3 Thai, 4 Filipino, 5 Vietnamese, 6 Spanish.
      biz_noyours, unique per attemptRequired. Up to 128 bytes.
      uuida stable pseudonymous user IDUp to 512 bytes. The docs' wording on when it is required is unclear.
      return_urlyour pageRequired. A browser redirect: for the screen only.
      notify_urlyour HTTPS endpointRequired. Ports 443, 5000, 16003, 8883 or 8028 only.
      scene_idyour console H5 sceneOptional. Sets the page's look and the flow timeout. A scene that does not exist gets 400 BAD_ARGUMENTS.
      orientation_mode1 on desktops and kiosks0 portrait (default). The docs say 1 "rotates for PC/PAD". Team position: 1 makes a desktop use its own webcam instead of the QR hand-off Unconfirmed · OI-24.
      get_fail_reason1Adds fail_reason: NETWORK_ERROR, PERMISSIONS_ERROR or SUPPORT_ERROR.
      action_http_methodPOST or GETHow the browser returns. GET appends ?biz_id=… to return_url.

      The response holds token (single use), biz_id, expired_time, request_id and error_message. Store biz_id with your biz_no.

      Open the page

      Send the browser to https://api-global.yljz.com/finauth/lite/do?token=<token>. The Access Guide uses api-idn.yljz.com for this page instead Conflict · OI-2. Use the same host as get_token and ask FinAuth to confirm (Q-2).

      The scene's flow timeout runs from get_token: 5, 10, 15, 30 or 60 minutes (default 60). Embedding the page in a cross-origin iframe is not confirmed by FinAuth Unconfirmed · OI-24. See Web.

      Read the result: get_result

      GET https://api-global.yljz.com/finauth/lite/get_result?api_key=…&api_secret=…&biz_id=…&return_image=0
          (auth style A, backend only, never log this URL)

      Look up by biz_id. It works in testing. The docs' parameter table lists only biz_no, and its "Required" label has changed twice Conflict · OI-1.

      • Results are kept for 1 day. At most 3 reads.
      • The decision is the same as for RAW flash. status=OK means finished, not passed.
      • liveness_result.result is a real anti-spoof verdict only with fmp_mode=0.
      • liveness_result.face_genuineness adds synthetic-face, mask and screen-replay scores. A confidence above its threshold means an attack.
      • H5 lite adds three statuses: WEBRTC_UNSUPPORTED, CANCELLED and TIMEOUT. See Error codes.

      Billing

      comparison_typeBilled whenNot billed
      0verify_result.error_message is null or NO_FACE_FOUNDOther error_message values Inferred. Cancelled or timed out with no verify_result Inferred · OI-16
      -1The flow ends with status=OKOther statuses Inferred
      any—Repeat get_result calls

      Example: a reference image with no detectable face is billed as NO_FACE_FOUND on every attempt. Check each reference image once, before you use it.

      Sources: Master §4.1, §4.2, §5.4 (EP-6…EP-9), §5.4.1, §5.4.2, §6.2, §8.3, §12 (DISC-12, DISC-14), §13 #1, #2; EC-22, EC-23; OI-1, OI-2, OI-16, OI-24, OI-33.

      Premium SDK

      Unconfirmed until on-device testing (OI-51)

      Device behaviour in this section comes from the vendor package, a decompiled POC app and the POC reports. SDK 5.9.0A has not been tested on a device.

      The Premium SDK is FinAuth's native camera SDK for your Android app. It adds checks on the device itself, such as root, hooking and emulators, to the cloud anti-spoofing Inferred. It needs console approval of your app ID.

      Flow

      1. Token. Your backend calls get_biz_token with your liveness_id. It returns a biz_token, valid for 1 hour. It is not billed Inferred.
      2. Capture. Your app starts the SDK with the biz_token. The SDK returns 1000 LIVENESS_FINISH: the capture and upload finished. It is not the verdict.
      3. Verify. Your app tells your backend. Your backend calls verify with the biz_token, your verify_id and the accepted ID as image_ref1. result_code with result_message is the verdict.

      Skip the separate liveness call. verify already includes liveness. Calling both on one token may be billed twice (REC-P2, OI-8). Use a new token for every attempt.

      Server requests

      POST https://api-sgp.yljz.com/finauth/v5/sdk/get_biz_token      (auth style B)
      sign, sign_version = hmac_sha1
      liveness_id        = <your Liveness Scene ID>
      uuid               = <stable pseudonymous user ID, up to 128 bytes>
      
      POST https://api-sgp.yljz.com/finauth/v5/sdk/verify             (auth style B)
      sign, sign_version = hmac_sha1
      biz_token          = <from get_biz_token>
      data_type          = 0           (1 = your own photo, with no liveness)
      comparison_type    = 0           (the only value)
      verify_id          = <your Verify Scene ID>
      encryption_type    = 0
      image_ref1         = (file) the accepted ID image
      biz_no             = <yours>

      Keys: get_biz_token uses "liveness detection and deepfake detection" (SEC-3 / SEC-4). The POC app signed verify with "1:1 face comparison" (SEC-5 / SEC-6). If verify returns 400 DATA_APPKEY_NOT_MATCH, sign it with the key that created the token Inferred.

      Console setup

      • Mobile Client. Register your Android applicationId. Finlaris approves it by hand. Wait for "In use". Without it: 1003 AUTHENTICATION_FAIL:{bundle_not_exist}.
      • Liveness Scene gives your liveness_id. Device risk, face risk, high-risk blocking and screen recording are settings here.
      • Verify Scene gives your verify_id. Create it under "Verify Config Scenes", not "Compare Config Scenes". Its "Compare Strictness" picks the threshold that turns a score into 1000 or 2000.

      Android integration

      • AAR. facelivev5.aar from the vendor package FaceLiveStill 5.9.0A. The POC apps used 5.8.19.3A. The public API is the same.
      • Edition. Standard collects extended device data for device-risk scoring: security state, accessibility services, network details and more. That needs a privacy notice. Basic collects none and gives no device-risk data (REC-A11). Choose on purpose.
      • Model. Bundle facelivemodel.bin, copy it to app storage, and pass its absolute path to FaceLiveDetectConfig. Missing: 1002 ILLEGAL_PARAMETER:{facelivemodel}.
      • ARM only. Native libraries ship for arm64-v8a and armeabi-v7a. An x86 emulator fails with 4200 LOAD_LIB_FAILED. Test on a physical device.
      • Upload timeout. uploadTimeout defaults to 5 seconds. A slower upload fails with 1019. Raise it with setUploadTimeout, for example to 15–30 seconds (REC-A8) Unconfirmed · OI-51.
      • Language. 5.9.0A ships English default text. 5.8.19.3A defaulted to Chinese Unconfirmed · OI-51.
      • Permissions. CAMERA and INTERNET are enough for liveness. If the scene turns on screen-recording evidence and you target SDK 35 or later, add RECORD_AUDIO and a foreground-service permission.
      • Networks. The SDK calls undocumented endpoints: /finauth/v5/sdk/internal/get_license_and_config, /grant_access and /upload. Allow them through corporate firewalls.
      • R8 / ProGuard. Keep the three packages the vendor's "Integration Diff" lists:
      -keep class com.face.lv5.**
      -keep class com.zenith.**
      -keep class com.neutralbase.safex.**

      Result codes

      Branch on result_code and result_message together: two different results share 4100. Treat any other code as a denial.

      result_coderesult_messageMeaningBilled
      1000SUCCESSLive, and the same person at the scene's strictness.✅
      2000PASS_LIVING_NOT_THE_SAMELive, but a different person.✅
      4100FAIL_LIVING_FACE_ATTACKSpoof, or liveness failed.✅
      4100VIDEO_LACK_FRAMESNot enough frames. Retry with a new token.❌

      A POC report spelled the 2000 message FACE_LIVING_NOT_THE_SAME (DISC-3). Match the docs' spelling above.

      face_risk_info is null unless the result is 4100 and Face Risk Detection is on in the scene (EC-12). Decide a policy for device_risk_info before you go live (REC-A5).

      SDK client codes (Android)

      CodeNameWhat you do
      1000LIVENESS_FINISHCall verify on your backend. Not the verdict.
      1001BIZ_TOKEN_DENIEDGet a new token.
      1002ILLEGAL_PARAMETER:{…}Fix the config. Bundle the model.
      1003AUTHENTICATION_FAIL:{…}Register the Mobile Client. Check the device clock and liveness_id.
      1011USER_CANCELLet the user start again.
      1012NO_CAMERA_PERMISSIONExplain why, then ask again.
      1016LIVENESS_FAILURERetry with a new token.
      1017GO_TO_BACKGROUNDRetry.
      1018LIVENESS_TIME_OUTRetry.
      1019DATA_UPLOAD_FAIL, DATA_UPLOAD_TIMEOUTRaise uploadTimeout. Check the network and firewall.
      4200LOAD_LIB_FAILEDUse a physical ARM device. The vendor doc says 4205; the SDK returns 4200 Conflict.
      5000API_KEY_BE_DISCONTINUED, IP_NOT_ALLOWED, BALANCE_NOT_ENOUGH, EXPIRED_SIGN, INVALID_SIGN, CONCURRENCY_LIMIT_EXCEEDED, …Check the account, balance and keys. Missing from the vendor doc's table.

      iOS: no iOS SDK binaries are in the sources. Not covered.

      Sources: Master §3.3, §5.2 (EP-1…EP-3), §5.2.1, §5.2.2, §6.3, §7.4, §8.1, §9.6, §12 (DISC-3), §13 #44; EC-5, EC-12; REC-A5, REC-A8, REC-A10, REC-A11, REC-P2; OI-8, OI-51.

      Callbacks and results

      Four signals tell you that a capture has ended. Only one of them is signed. None of them is the verdict.

      What arrives

      SourceArrives asSigned?What you do
      H5 lite notify_urlServer-to-server POST: data (a JSON string) and signYes: sha1(api_secret + data), lowercase hexVerify the sign on the raw data. Dedupe by biz_id. Confirm with get_result before you act (SEC-REV-5).
      H5 lite return_urlBrowser redirect: a form POST, or GET with ?biz_id=No. Whether the POST is signed is unknown Unconfirmed · OI-14For the screen only. Show "Checking your result…". Never decide from it.
      RAW JS SDK resultCallbackA JavaScript call in your pageNo. Client-side onlyAsk your backend to read the result.
      Premium SDK 1000 LIVENESS_FINISHA callback in your appNo. Client-side onlyAsk your backend to call verify.
      Warning

      The callback has no timestamp or nonce. A captured callback can be replayed. Make the handler idempotent.

      Verify a notify_url callback

      The signature is plain SHA-1 over the secret followed by data. It is not HMAC. Treat a callback as valid only if all of these are true:

      • sign equals sha1(api_secret + data), computed on data exactly as received, compared in constant time.
      • biz_info.biz_id matches a session you created.
      • You have not processed this biz_id before. Mark it the first time you see it.

      Then read get_result and decide from that. The callback carries no images, no visual_attributes and no verify_risk_info.

      The docs require the signature check on non-HTTPS endpoints. Use HTTPS and check it anyway. FinAuth may call more than once. Which response it expects from your endpoint, and whether it retries, is not documented by FinAuth.

      // H5 lite notify_url handler. Copy and adapt. Not run.
      // FinAuth POSTs two form fields: data (a JSON string) and sign. Read data as the exact string received.
      import { verifyCallbackSign } from './finauth-sign.js';
      
      type NotifyResult = 'accepted' | 'rejected' | 'duplicate';
      
      // sessions and jobs: your own storage and job queue.
      export async function onNotify(fields: { data?: string; sign?: string }, h5Secret: string): Promise<NotifyResult> {
        const { data, sign } = fields;
        if (!data || !sign || !verifyCallbackSign(h5Secret, data, sign)) return 'rejected';   // 1. check the raw string
        let bizId: string | undefined;
        try {
          bizId = JSON.parse(data).biz_info?.biz_id;                                          // 2. parse only after the check
          if (!bizId) return 'rejected';
          if (!(await sessions.markNotified(bizId))) return 'duplicate';                      // 3. first time only: no replay protection
          await jobs.enqueue('h5-get-result', { bizId });                                     // 4. decide from get_result, not from data
          return 'accepted';
        } catch (e) {
          throw new Error(`notify_url handling failed for biz_id ${bizId ?? '(not parsed)'}`, { cause: e });
        }
      }

      Persistence

      • Persist on the first finished read (DISC-32, GL-16). Results expire after 1 day or 3 reads. Example: read 1 at 10:00 returns OK, then your server crashes before saving. Reads 2 and 3 work. A fourth read returns DATA_DESTROYED, and the user must start a new, billed session.
      • Store the evidence with the verdict: request_id, biz_no, biz_id (H5 lite), the level, the confidence and the threshold.
      • Never put image_best or ID images in logs (GL-27). Store them only where your retention policy allows.

      Sources: Master §5.4 (EP-9), §6.1, §6.6, §17.2, §17.3, §19 (GL-16, GL-27); OI-14; DISC-13, DISC-32.

      Products, keys and scenes

      Each product needs its own key. A key works only on the endpoints it is entitled to.

      ProductEndpointsAuthConsole key nameScene or app_idRegionBilled when
      AnyDocPOST /finauth/v1/anydocBThe AnyDoc key; name not in the sources (SEC-9 / SEC-10 Inferred)app_id builtin-laCUS0pQTRGOrEas0lSingaporeHTTP 200 and pages not empty: pages × unit price
      RAW flashPOST /finauth/lite/raw/get_token, wss://rtc-sgp.yljz.com, GET /finauth/lite/raw/get_resultA"liveness detection and deepfake detection" (SEC-3 / SEC-4)NoneSingaporeNot documented by FinAuth (OI-17). get_token errors are not billed
      H5 litePOST /finauth/lite/get_token, GET /finauth/lite/do, GET /finauth/lite/get_result, callbacksANot identified in the sources (OI-33)scene_id (optional)api-global Conflict · OI-2comparison_type=0: error_message null or NO_FACE_FOUND. -1: status=OK
      Premium SDKPOST /finauth/v5/sdk/get_biz_token, POST /finauth/v5/sdk/verifyBToken: "liveness detection and deepfake detection" (SEC-3 / SEC-4). Verify: "1:1 face comparison" in the POC (SEC-5 / SEC-6) Inferredliveness_id, verify_idSingapore or Indonesiaverify: 1000, 2000, 4100 attack. Not 4100 VIDEO_LACK_FRAMES

      These are the products this guide covers. Other products need their own key from Finlaris. None of the account's keys covers StarScope, ID Anti-Forgery or Credit Score. StarScope and ID Anti-Forgery returned 403 AUTHORIZATION_ERROR: Denied in testing (EC-8, EC-11). Credit Score is out of scope for now (EC-15).

      The alternative ID OCR uses "ID OCR for Philippines" (SEC-1 / SEC-2). It is refused on AnyDoc (EC-19).

      Sources: Master §3.3, §3.5, §5.0, §8.1, §9.6; EC-6…EC-20; OI-17, OI-33.

      App and web setup

      The capture page needs the camera. In a browser that means HTTPS and a permission prompt. Inside an app's WebView, your native code must also grant the camera to the page.

      Which origin asks for the camera depends on the mode. RAW flash: your page's origin, because your page runs the SDK. H5 lite: FinAuth's host. Grant the camera only to the origin you expect (SEC-REV-9).

      Android WebView

      • Declare CAMERA and INTERNET. Ask for CAMERA at runtime.
      • In WebChromeClient.onPermissionRequest, grant only RESOURCE_VIDEO_CAPTURE, only to the expected origin, and only when the app holds CAMERA. Deny everything else.
      • Turn JavaScript on. Set mediaPlaybackRequiresUserGesture = false.
      • Leave universal file access off. Never allow mixed content.
      <uses-permission android:name="android.permission.INTERNET" />
      <uses-permission android:name="android.permission.CAMERA" />
      // Copy and adapt. Not tested. The grant and the media setting follow Master §5.4.1;
      // the origin check follows SEC-REV-9. The other settings are standard Android hardening.
      class LivenessActivity : AppCompatActivity() {
          // The origin of the page that asks for the camera. RAW flash: your page. H5 lite: FinAuth's host.
          private val expectedOrigin = "https://kyc.example.com"
          private lateinit var webView: WebView
      
          private val askCamera = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
              if (granted) webView.loadUrl("$expectedOrigin/liveness") else showCameraNeeded()
          }
      
          override fun onCreate(savedInstanceState: Bundle?) {
              super.onCreate(savedInstanceState)
              webView = WebView(this).also { setContentView(it) }
              with(webView.settings) {
                  javaScriptEnabled = true
                  mediaPlaybackRequiresUserGesture = false
                  allowFileAccess = false
                  allowContentAccess = false
                  mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
              }
              webView.webChromeClient = object : WebChromeClient() {
                  override fun onPermissionRequest(request: PermissionRequest) {
                      val onlyCamera = request.resources.all { it == PermissionRequest.RESOURCE_VIDEO_CAPTURE }
                      val expected = request.origin.toString().trimEnd('/') == expectedOrigin
                      val appHasCamera = ContextCompat.checkSelfPermission(
                          this@LivenessActivity, Manifest.permission.CAMERA
                      ) == PackageManager.PERMISSION_GRANTED
                      if (onlyCamera && expected && appHasCamera) {
                          request.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE))
                      } else {
                          request.deny()
                      }
                  }
              }
              askCamera.launch(Manifest.permission.CAMERA)
          }
      }

      FinAuth's docs grant every requested resource (request.grant(request.getResources())). That gives the camera, and anything else asked for, to any page in the WebView (RISK-7). Check the resource and the origin instead.

      The Bastion Verifier POC loaded the RAW page from file:///android_asset/ and did not check the origin. That trades one risk for another. A bundled page cannot be swapped over the network, and an origin check adds little for a local file, so the POC rated it Low. In exchange, every RAW JS SDK update needs an app release. If you load the page over HTTPS, check the origin as shown.

      iOS WKWebView

      • Use WKWebView. FinAuth's compatibility table gives in-app WKWebView on iOS 14.3 or later 93 % WebRTC support.
      • Set allowsInlineMediaPlayback = true.
      • Set mediaTypesRequiringUserActionForPlayback to none.
      • Add NSCameraUsageDescription to Info.plist. FinAuth's docs leave it out, but iOS requires it for camera access.
      <key>NSCameraUsageDescription</key>
      <string>We use the camera to check that you are the person on your ID.</string>
      // Copy and adapt. Not tested. Master §5.4.1 lists WKWebView, inline playback, no user action for media
      // and NSCameraUsageDescription. The permission delegate (iOS 15+) is Not in FinAuth docs; not tested.
      import UIKit
      import WebKit
      
      final class LivenessViewController: UIViewController, WKUIDelegate {
          private let expectedHost = "kyc.example.com"     // the host of the page that asks for the camera
      
          override func viewDidLoad() {
              super.viewDidLoad()
              let config = WKWebViewConfiguration()
              config.allowsInlineMediaPlayback = true
              config.mediaTypesRequiringUserActionForPlayback = []
              let webView = WKWebView(frame: view.bounds, configuration: config)
              webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
              webView.uiDelegate = self
              view.addSubview(webView)
              if let url = URL(string: "https://\(expectedHost)/liveness") { webView.load(URLRequest(url: url)) }
          }
      
          @available(iOS 15.0, *)
          func webView(_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin,
                       initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType,
                       decisionHandler: @escaping (WKPermissionDecision) -> Void) {
              let allowed = origin.protocol == "https" && origin.host == expectedHost && type == .camera
              decisionHandler(allowed ? .grant : .deny)
          }
      }

      Web

      • Serve the page over HTTPS. FinAuth requires it, and browsers give the camera only to secure pages.
      • Serve rtcsdk.min.js from your own origin.
      • H5 lite in an iframe: put allow="camera" on that iframe only, and limit the CSP frame-src to the FinAuth host (SEC-REV-10). FinAuth has not confirmed that the page works in a cross-origin iframe Unconfirmed · OI-24.
      • RAW flash with a CSP: allow wss://rtc-sgp.yljz.com in connect-src. Not in FinAuth docs; not tested. The full list of hosts the SDK contacts is not documented by FinAuth. When host was missing, the POC saw it call a track_log path as well as raw_signal (TRD-H5 §11).
      <!-- H5 lite in an iframe. Iframe support is unconfirmed (OI-24). -->
      <iframe src="https://api-global.yljz.com/finauth/lite/do?token=…" allow="camera" title="Face verification"></iframe>
      Content-Security-Policy: frame-src https://api-global.yljz.com; connect-src 'self' wss://rtc-sgp.yljz.com

      WebRTC compatibility

      FinAuth publishes this table for H5 lite. RAW flash also depends on WebRTC. H5 lite can fall back to a still video. RAW flash cannot: it ends with 4000 SUPPORT_ERROR.

      PlatformEnvironmentWebRTC supportIf it fails
      iOSSafari 11+ (iOS 11.1.2+)100 %—
      iOSIn-app WKWebView, iOS 14.3+93 %H5 lite: still video. RAW flash: suggest Safari.
      iOSQQ, Baidu, Chrome100 %—
      AndroidIn-app WebView, Android 7+95 %H5 lite: still video. RAW flash: suggest Chrome.
      AndroidPreinstalled browsers: Huawei, Vivo, Honor, Samsungabout 80 %Suggest Chrome.
      AndroidPreinstalled browsers: OnePlus, Xiaomi, OPPOabout 40 %Suggest Chrome.
      AndroidQQ / Baidu / UC100 % / 40 % / 20 %Suggest Chrome.
      HarmonyOSNEXT 5.0+100 %—
      iOS, HarmonyOSUC browserNot supportedAsk the user to switch browser.
      DesktopAnyNot covered by the tableTest your target browsers.

      Cross-platform frameworks

      The Android and iOS rules above still apply underneath each framework.

      FrameworkWhere the RAW page runsCamera permissionGotchas
      Capacitor / IonicIn the app's own web layer Inferred, or your HTTPS pageAndroid runtime CAMERA. iOS NSCameraUsageDescription. Check that the WebView grants the camera to the page.The page runs on a local app origin, which FinAuth does not document. The SDK file ships inside the app. Not in FinAuth docs; not tested.
      React NativeYour HTTPS page in react-native-webviewAndroid runtime CAMERA. iOS NSCameraUsageDescription. Props: allowsInlineMediaPlayback, mediaPlaybackRequiresUserAction={false}, mediaCapturePermissionGrantType (iOS 15+).Check which origins the WebView grants the camera to. Not in FinAuth docs; not tested.
      FlutterYour HTTPS page in webview_flutter or flutter_inappwebviewAndroid runtime CAMERA, then grant in the WebView's permission-request callback for your origin only. iOS NSCameraUsageDescription and inline media playback.Permission support differs between plugin versions. Test both platforms. Not in FinAuth docs; not tested.
      // Capacitor / Ionic. Copy and adapt. Not tested.
      // Your app's own web layer can host the RAW JS SDK (inferred). Ship dist/rtcsdk.min.js in the app's
      // assets and load it with a <script> tag in index.html. An SDK update then needs an app release.
      declare const RtcVideo: any;
      
      export async function startLiveness(sessionId: string, apiBase: string, authToken: string): Promise<void> {
        let token: string;
        try {
          const res = await fetch(`${apiBase}/kyc/liveness`, {          // absolute URL: the app runs on a local origin
            method: 'POST',
            headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}` },
            body: JSON.stringify({ sessionId }),
          });
          if (!res.ok) throw new Error(`backend returned ${res.status}`);
          ({ token } = await res.json());
        } catch (e) {
          throw new Error('Could not get a liveness token from your backend', { cause: e });
        }
        // Same options as the web page example: videoWrapper id, host, token, timeouts, listeners.
        const rtc = new RtcVideo({ videoWrapper: 'liveness-video', host: 'wss://rtc-sgp.yljz.com', token, ...listeners() });
        rtc.start();
      }

      Sources: Master §5.4.1, §5.5 (EP-11), §5.5.1, §9.7, §15 (RISK-7, RISK-8), §17.4 (SEC-REV-9, SEC-REV-10, SEC-REV-18); OI-24.

      UX guidance

      Show the user where they are in the check. There are ten states.

      The states

      Not started Checking ID Ready for liveness Liveness running ID not accepted Checking result Verified Not matched Liveness failed Couldn't finish send ID accepted start not accepted resultCallback 1000 SDK ≠ 1000 confidence > threshold confidence ≤ threshold liveness FAIL not OK, or an error
      Green is the only good end state. From ID not accepted, the user can send another photo. From the other red states, the user can try again with a new token, up to your attempt cap.
      StateWhenShow
      Not startedNo ID yet.Your consent and privacy notice, then "Scan your ID" and "Upload your ID". List the ID types you accept.
      Checking IDYour backend is calling AnyDoc."Checking your ID…". Disable the buttons.
      ID not acceptedThe type is not on your list, or a required field is empty."We couldn't use this ID." The accepted types. "Try another photo": whole card in frame, no glare.
      Ready for livenessThe ID was accepted.The preparation tips (below) and a "Start" button.
      Liveness runningstart() was called.The video, live tips from tipListener, the colour flashes and a Cancel button.
      Checking resultresultCallback(1000). Your backend is reading the result."Checking your result…". Never "Verified" yet.
      VerifiedThe decision is verified."You're verified." Continue.
      Not matchedConfidence at or below the threshold."Your face didn't match your ID." One retry, then another path.
      Liveness failedliveness_result.result is FAIL."We couldn't confirm it's you, live." The preparation tips. One retry, then another path.
      Couldn't finishSDK code 2000, 3000, 4000 or 5000; status not OK after 3 reads; an error_message; a backend error."We couldn't finish the check." A "Try again" button that gets a new token.

      Rules

      • Consent first. Ask for consent and show a privacy notice before the camera opens. Biometric data is sensitive personal information under the Philippine Data Privacy Act Unconfirmed · Compliance review. Name FinAuth as the processor and say where data goes: Singapore for RAW flash.
      • Prepare the user. Before the flash, tell them: face a light, take off glasses and masks, turn up screen brightness, and hold the phone at eye level (TS-5).
      • Tips in plain words. Map every tipCode to your own text. See Tip codes.
      • Get the token when the user taps Start. Not on page load. Example: a token with life_time=60 fetched at 10:00 and used at 11:05 is likely to fail with 5000 TOKEN_EXPIRED Inferred.
      • A new token for every attempt, and a cap. Cap attempts per user, for example 5 per hour (REC-G7). Failed spoof attempts may be billed. Example: a user who has tried 5 times since 10:00 waits until 11:00 or takes another path.
      • Wait for the verdict. Show "Checking your result…" between resultCallback(1000) and your backend's answer. Never show "Verified" from the client callback.
      • Real users sometimes fail. RAW flash has returned FAIL for real, live people, and the cause is not known (OI-53). Offer one retry, then another path, such as H5 lite or a manual review.
      • status=OK means finished, not passed. Never show success from the status alone.

      Sources: Master §5.5 (EP-11, EP-12), §7.2, §7.3, §16.2, §16.3, §18.3 (REC-G7), §20 (TS-5); OI-53.

      Troubleshooting

      FinAuth tells you why it rejected a request. Read three things: the HTTP status, the error string (error or error_message) and the business result in the body. Log request_id with all three.

      How FinAuth rejects a request

      FinAuth does not document its checking order. This order is read from the error families Inferred. The first failure ends the request.

      1. 404 API_NOT_FOUND: wrong host or path.
      2. 403 AUTHENTICATION_ERROR: the sign, or the key and secret, do not check out.
      3. 403 AUTHORIZATION_ERROR:<reason>: entitlement, IP, quota or an expired sign.
      4. 403 CONCURRENCY_LIMIT_EXCEEDED: over your QPS.
      5. 400 MISSING_ARGUMENTS or BAD_ARGUMENTS: a field is missing or invalid.
      6. 400 image or file errors.
      7. 413: the request is too large.
      8. 200 with a business result: status, result_code or error_message in the body.

      How to debug a rejected request

      1. Run the test vectors through your code. If raw or the sign differs, fix signing first.
      2. Paste one of your real signs into the sign inspector. Check the key, the seconds and the TTL. A sign holds your api_key, not your secret.
      3. Check that the key belongs to the product you are calling. See Products, keys and scenes.
      4. Check your server clock. Keep NTP on.
      5. Log request_id, biz_no and, for H5 lite, biz_id for every call. Never log the secret, images or full get_result URLs.
      6. Send FinAuth the request_id, the time, the endpoint and the error.

      Symptoms

      SymptomLikely causeFix
      The camera does not open in your appPermission denied. Android: no onPermissionRequest grant, or no runtime CAMERA. iOS: not WKWebView, no inline playback, or no NSCameraUsageDescription.Apply App and web setup. Check the app's permissions.
      The camera does not open on your websiteNo WebRTC (UC, some preinstalled Android browsers). The iframe lacks allow="camera". The page is not HTTPS.Chrome or Safari. HTTPS. allow="camera". H5 lite: procedure_priority=still.
      A desktop shows a QR code (H5 lite)The default orientation_mode=0.Scan it with a phone, or send orientation_mode=1 Unconfirmed · OI-24.
      H5 page stuck on "Do not change your browser during the verification process.", then TIMEOUTNetwork or WebRTC blocked. Flash unsuited to the device or light. Token reused. Scene flow timeout.New token. Try procedure_type=still. Another network or browser. Send FinAuth the request_id, biz_id, device, browser and procedure type.
      Liveness always failsPoor light or backlight, face off-centre, glasses glare, low screen brightness, a mask. A real spoof. A configuration problem.Prepare the user (UX rules). Read the liveness_result.details counters. Check the configuration.
      "Not matched" for the right personA poor ID photo, a level that is too strict, two faces on the ID, an old photo.Retake the ID photo. Use 1e-4 or 1e-5, not 1e-6. Send fail_when_ref_multiple_faces=0.
      RESULT_NOT_FOUNDWrong lookup ID, wrong host, or older than 1 day.biz_id for H5 lite, token for RAW flash. Read promptly.
      DATA_DESTROYEDMore than 3 reads, or older than 1 day.Persist on the first read. Start a new session.
      Billed, but you have no resultThe result was lost after 3 reads or 1 day. Your server crashed after FinAuth processed it. A double call. H5 lite NO_FACE_FOUND.Persist the first read. Make handlers idempotent. Reconcile with FinAuth by request_id.
      AUTHENTICATION_ERROR, INVALID_SIGN or EXPIRED_SIGNWrong key pair. A rotated secret. Clock skew. URL-safe Base64.Run the test vectors. Turn on NTP. Use standard Base64 and the right pair.
      AUTHORIZATION_ERROR: Denied, NO_*_PERMISSION or LIMIT_REACHEDThe product is not enabled on the key, or the test quota is used up.Ask Finlaris.
      403 INVALID_SIGN, Denied or NO_PERMISSION on a new productWrong key for the product, or another key's secret.Use the product's own pair. Ask Finlaris for a key if there is none.
      CONCURRENCY_LIMIT_EXCEEDEDOver QPS: 2 on test keys.Back off and queue.
      413 Request Entity Too Large, and the body is not JSONThe file is over the limit.Compress. See Size limits.
      400 MISSING_ARGUMENTS:image_ref1The reference field is misnamed.Name it exactly image_ref1.
      RAW SDK 4000 NETWORK_ERRORhost is missing. 2.0.3 has no default.Set host: 'wss://rtc-sgp.yljz.com'.
      SDK says 1000, but the user is not verified1000 means finished. The server said not matched or failed.Show the server's verdict, never the SDK code.
      The notify_url callback never arrivesPort not allowed. Firewall.Use port 443. Open the firewall. Fall back to get_result.
      OCR returns empty fieldsThe ID type is not supported by PH_ID. Glare. Low quality.Use AnyDoc for other Philippine IDs. Better light.
      Premium: AUTHENTICATION_FAIL:{bundle_not_exist}App ID not registered, still "Under review", or different from applicationId.Register it. Wait for "In use". Match applicationId.
      Premium: 1002 ILLEGAL_PARAMETER:{facelivemodel}Model not bundled, or a wrong path.Bundle facelivemodel.bin. Pass its absolute path.
      Premium: 4200 LOAD_LIB_FAILEDAn x86 emulator.Use a physical ARM device.
      Premium: 1019 DATA_UPLOAD_FAILUnstable network. A firewall blocks the SDK's internal upload. The 5-second uploadTimeout.New token and a stable network. Allow-list the internal endpoints. Raise uploadTimeout.
      Premium: SDK screens in ChineseSDK 5.8.19.3A default strings.Set the language to en. Upgrade to 5.9.0A Unconfirmed · OI-51.

      Symptoms seen in the POC apps

      SymptomLikely causeFix
      First POC app: a fake ID passes the ID checkThe app looked for the wrong OCR risk-flag names and treated null as a pass (DISC-6).Read the documented flag names. Treat null as unverified (REC-A4).
      Casino Web: the 1:1 step says "configuration error"VerifyId is missing.Configure the Verify Scene ID.
      Casino Web: the network monitor shows "very slow"Latency to the app server, not bandwidth.Check the app server and network. The monitor is advisory only.
      Bastion Verifier: "1:1 result unavailable." right after livenessget_result was still PROCESSING, liveness failed, or the network failed. The app does not retry.Read again with the same token, at most 3 times (REC-M6).
      Bastion Verifier: "RAW LIVENESS FAILED 5000 - JS_ERROR:…"The app's own page threw an exception. It is not a FinAuth error.Check that rtcsdk.min.js loaded. A real FinAuth 5000 says TOKEN_… or ILLEGAL_PARAMETER.
      Bastion Verifier: "ID VALIDATED" for a wrong documentThe app checks only error_message.Apply the acceptance rule (REC-M5).

      Error codes

      Grouped by family. Billed: ✅ yes, ❌ no, or "Not documented". RAW JS SDK codes are in Result codes. Premium SDK codes are in Premium SDK.

      Authentication, permission and rate (403)

      CodeHTTPWhereMeaningFixBilled
      AUTHENTICATION_ERROR403AllBad sign, or the key and secret do not match. RAW flash says "Invalid signature" even though it has no sign.Run the test vectors. Pair each key with its own secret.❌
      AUTHENTICATION_ERROR:NO_APP_PERMISSION403AnyDocThe app_id is not enabled on your key.Ask Finlaris.❌
      AUTHORIZATION_ERROR:INVALID_SIGN403Style B productsThe sign does not verify: wrong secret for the key, or a bad raw string. OCR reports a bad sign as AUTHORIZATION_ERROR without a reason Conflict.See How to sign a request.❌
      AUTHORIZATION_ERROR:EXPIRED_SIGN403Style B productsb has passed, or the clock is off Inferred.NTP. A TTL of about 100 seconds.❌
      AUTHORIZATION_ERROR: Denied403Any productThe key is not entitled to this product. Seen on StarScope, ID Anti-Forgery and AnyDoc.Use the product's own key. Ask Finlaris to provision one.❌
      AUTHORIZATION_ERROR:NO_VERIFY_PERMISSION, NO_LIVENESS_PERMISSION, NO_DATA_SOURCE_PERMISSION, NO_EID_PERMISSION403Per productThe product is not provisioned.Ask Finlaris.❌
      AUTHORIZATION_ERROR:LIMIT_REACHED403AllThe test-key quota is used up.Ask Finlaris.❌
      AUTHORIZATION_ERROR:IP_NOT_ALLOWED403AllYour server's IP is not allowed.Ask Finlaris to add it.❌
      AUTHORIZATION_ERROR:API_KEY_BE_DISCONTINUED403AllThe key was disabled.Get a new key.❌
      AUTHORIZATION_ERROR:MORE_RETRY_TIMES403Premium, RAW flashToo many retries.Back off.❌
      AUTHORIZATION_ERROR:5001, 5002, 5007403OCRNumeric reasons. Not documented by FinAuth.Ask FinAuth.❌
      CONCURRENCY_LIMIT_EXCEEDED403AllOver the QPS limit. Test keys: 2.Back off and queue.❌
      DATA_DESTROYED403RAW get_result3 reads used, or older than 1 day.Persist on the first read.❌

      Request, image and file (400)

      CodeHTTPWhereMeaningFixBilled
      MISSING_ARGUMENTS:<key>400AllA field is missing. Seen when image_ref1 was misnamed.Send the exact field name.❌
      BAD_ARGUMENTS:<key>400AllAn invalid value: an empty uuid, an unknown scene_id or app_id, or (Premium) a token the SDK never used.Fix the value.❌
      IMAGE_ERROR_UNSUPPORTED_FORMAT400MostNot JPG or PNG. H5 lite spells it …FORMA.Send JPG or PNG.❌
      INVALID_IMAGE_SIZE400MostOver 4096 px (OCR: 5000 px).Resize.❌
      NO_FACE_FOUND400Premium, RAW and H5 get_tokenNo face in an image.Use a better image.❌ (but H5 verify_result NO_FACE_FOUND is billed)
      MULTIPLE_FACES400MostMore than one face. The docs also say the largest face is used Conflict · OI-9.fail_when_ref_multiple_faces=0 (RAW, H5), or crop.❌
      LOW_QUALITY400Premium, CompareImage quality below the threshold.Use a better image.❌
      INVALID_TOKEN400H5 liteBad token.Get a new token.❌
      RESULT_NOT_FOUND400H5 and RAW get_resultWrong lookup ID.biz_id (H5 lite), token (RAW).❌
      VIDEO_FACE_OCCLUDE400H5 get_resultThe face was covered.Ask the user to uncover it.❌
      FACELIVE_DATA_ERROR, FACELIVE_DATA_BIZ_TOKEN_NOT_MATCH400Premium verifyBad SDK data, or the wrong token.Use the matching token. Do not modify the data.❌
      DATA_APPKEY_NOT_MATCH400Premium verifyA different key from the one that made the token.Use the same key for the token and verify.❌
      KEY_NOT_FOUND400Premium verify, AnyDocEncryption keys are not configured.Send encryption_type=0.❌
      FILE_PARSE_FAILED, FILE_EXTRACT_FAILED, INVALID_INPUTS_PARAM, FILE_TOO_LARGE, FILE_UNSUPPORTED_FORMAT, TOO_MANY_FILES400AnyDocFile problems.One file, at most 9 MB, jpg, jpeg, png, gif, webp or PDF.❌
      ID_CARD_NOT_FOUND400OCRNo card found.Retake the photo.❌

      Routing, size and server

      CodeHTTPWhereMeaningFixBilled
      API_NOT_FOUND404AllWrong host or path.Check the host and region.❌
      Request Entity Too Large (plain text)413AllOver the size limit.Compress. See Size limits.❌
      INTERNAL_ERROR500AllServer error.Retry with backoff, then contact support with the request_id.❌

      Result values in get_result (H5 lite and RAW flash)

      ValueWhereMeaningWhat you doBilled
      NOT_STARTEDH5, RAWThe user has not begun.Wait. Read again within the 3-read limit.❌ Inferred
      PROCESSINGH5, RAWIn progress.Read again later, at most 3 reads in all.❌ Inferred
      WEBRTC_UNSUPPORTEDH5The browser has no WebRTC.procedure_priority=still, or another browser.Not documented
      OKH5, RAWFinished. Not passed.Run the decision.H5: see billing. RAW: not documented
      FAILEDH5, RAWThe 30-second framing step failed.Retry with better light.Not documented
      CANCELLEDH5The user left.Offer a retry.❌ Inferred
      TIMEOUTH5The flow timed out.New token. Try still.❌ Inferred
      verify_result.error_message nullH5, RAWThe comparison ran.Compare with the threshold.H5 ✅. RAW: not documented
      verify_result.error_message NO_FACE_FOUNDH5, RAWNo face in the reference or the live image.Use a better reference.H5 ✅. RAW: not documented
      verify_result.error_message DATA_SOURCE_ERRORH5, RAWA data-source failure.Stop and contact FinAuth.Not documented
      verify_result.error_message INTERNAL_ERRORH5, RAWServer error.Retry later.Not documented

      Common mistakes

      • URL-safe or unpadded Base64. A correct sign uses +, / and =. The test vector ends in ==.
      • The hex digest instead of the bytes. Base64 of the 40-character hex gives a 136-character sign. The right one is 112.
      • Raw before MAC. The MAC comes first.
      • Milliseconds. Date.now() gives 1800000000000. You need 1800000000.
      • A reused or weak d. Use a new value from a cryptographic random generator for every request.
      • One product's key with another's secret. Each pair belongs together.
      • Hashing a re-serialized callback. Hash data as received.
      • Trusting return_url or resultCallback(1000). Neither is a verdict.
      • A hard-coded threshold. Read thresholds[level] from the same response.
      • Ignoring PROCESSING. Read again, within 3 reads.
      • Burning the 3 reads. Persist the first finished read.
      • Secrets in the app or in logged URLs. Keys stay on the backend. get_result URLs stay out of logs.
      • No RTC host. Since 2.0.3 there is no default.
      • A misnamed image_ref1. The first image field must be named exactly that.

      Sources: Master §7.1–§7.5, §17, §20 (TS-1…TS-31, except TS-24, FaceSearch); DISC-6, DISC-38; REC-A4, REC-M5, REC-M6.

      Known limits

      These limits are real. Plan around them.

      Warning
      • Secrets must stay on your backend. A FinAuth key is long-lived and billed. Anything shipped in an app or a page can be extracted.
      • The callback signature is weak. It is plain SHA-1 over the secret and the data, with no timestamp or nonce. A captured callback can be replayed (OI-14).
      • get_result carries the secret in a query string. Keep it on the backend and out of logs.
      • Test keys only. QPS 2, test quotas, and no production keys yet.
      • The vendor docs contradict themselves. 56 inconsistencies are recorded (Master §13). These affect this flow: the H5 lite hosts (OI-2), biz_id or biz_no on get_result (OI-1), the RctVideo name in the JS sample, invalid JSON in sample responses, and the AnyDoc cardType spellings (Q-29). Test against FinAuth, not against the samples.
      • RAW flash is Singapore only, and it always compares. There is no liveness-only RAW flash (EC-18).
      • Results last 1 day and 3 reads. After that they are gone.
      • Thresholds change per call. The RAW attack scores, flash_attack and living_attack, have no documented thresholds (OI-20).
      • RAW flash sometimes fails real users. The cause is unknown (OI-53).
      • AnyDoc accepts unsupported documents. It puts any document into a Philippine type and bills the page (EC-27, EC-30).
      • Billing has open questions. RAW flash billing is not documented (OI-17). Premium liveness plus verify on one token may bill twice (OI-8). H5 lite billing for failed, cancelled and timed-out flows is inferred (OI-16). Unit prices are unknown (OI-44).
      • H5 lite on desktops is untested. orientation_mode=1 is expected to use the webcam instead of the QR code, but nobody has tested it (OI-24). Iframe support is unconfirmed.
      • There is no Philippine government-record check. FinAuth documents record checks only for Indonesia and Mexico (OI-47). An ID that reads cleanly is not proven genuine.
      • No data-retention policy yet. Retention, consent and deletion for selfies, ID images and image_best are undecided (D-5). Where api-global processes data is unconfirmed (OI-2, OI-36).

      Products not covered

      ProductWhy not covered
      FaceSearch (1:N)Out of scope for this flow. It has its own key; that key's secret is not in the sources.
      StarScope (deepfake detection)No key: the account's keys get 403 Denied (EC-11).
      ID Anti-ForgeryNo key: 403 Denied (EC-8). For Philippine IDs, Finlaris points to the OCR risk flags.
      Credit ScoreNot provisioned. Out of scope (EC-15).
      Mexico INEOut of scope: Mexican records only.
      Indonesia ID checkOut of scope: Indonesian records only.
      Basic SDK (API v3)Superseded by the Premium SDK.
      RAW stillOut of scope: it checks an uploaded video, and this flow captures live.

      POC lessons

      Earlier Bastion POCs made these mistakes. Each has a recorded fix.

      LessonWhat happenedFix
      Secrets in appsBoth Android POC apps shipped FinAuth keys and secrets and called FinAuth from the phone (DISC-1, DISC-33).A backend gateway (REC-G1, REC-M1).
      A logged secretBastion Verifier wrote the liveness secret to the device log inside the get_result URL (DISC-34).No URL or body logging. get_result from the backend only (REC-M2).
      A fixed threshold of 75Casino Web and Bastion Verifier compared the confidence with 75 (DISC-9, DISC-36).thresholds[level] from the same response (REC-G4, REC-M4).
      Reused livenessCasino Web compared an earlier image_best with the ID, photo to photo, with no liveness in that step (DISC-10).One session with the ID as image_ref1 (REC-C1, REC-P5).
      No fmp_modeThe H5 lite POCs relied on PASS/FAIL without stating fmp_mode=0 (DISC-12).Send fmp_mode=0 (REC-H2).
      Unverified callbacksCallback signatures were not checked (DISC-13).Verify, dedupe, then confirm with get_result (REC-H1).

      Sources: Master §1, §3.3, §4.1, §5.5, §5.11, §8, §9.6, §12, §13, §14, §16, §17; EC-8, EC-11, EC-15, EC-18, EC-27, EC-30.

      Changelog and versions

      This is version 0.4 of the guide, for a proof of concept. Everything in it was checked against the sources on 2026-11-04.

      ItemVersionStatus or dateNotes
      This guide0.4POC, generated 2026-11-04The POC runner moved out of this guide into its own page. This guide is documentation only: it works opened as a file and makes no network calls apart from fonts.
      This guide0.3POC, generated 2026-11-04API Key and API Secret fields on every API step, with a Settings panel that fills them. Double-click launchers that need no installed Node. Billing guards: no retries, one call per step at a time, AnyDoc answers reused for the same image.
      This guide0.2POC, generated 2026-11-04A POC runner section and a local gateway were added. They moved out in 0.4.
      This guide0.1POC, generated 2026-11-04First version. Audience: internal. Primary flow: AnyDoc, then RAW flash, then raw/get_result. Tracks: H5 lite, Premium SDK.
      FinAuth APIv5CurrentPremium and server products under /finauth/v5. H5 lite and RAW flash under /finauth/lite. AnyDoc under /finauth/v1.
      RAW JS SDK2.0.32025-08-22host is required; there is no default.
      Premium SDK (Android)5.9.0AVendor doc 2026-10-14The POC apps used 5.8.19.3A. Not yet tested on a device.
      FinAuth docs site—Last checked 2026-11-0453 pages.

      Sources: Master Observations §1, §3–§10, §12–§20, §22 (§24 excluded); External Confirmations EC-3…EC-30; FinAuth Reference; H5 lite Observations; FinAuth Test APIs Postman collection.

      FinAuth Integration Guide · 0.4 · POC