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.
This guide describes a proof of concept. Test keys only. Read Known limits before you build on it.
The flow in four steps
- 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.
- Liveness session. Your backend calls
raw/get_tokenwith the accepted ID asimage_ref1. Your app receives only the token and runs the RAW JS SDK with it. - Result. The SDK reports
1000: the capture finished. Your backend readsraw/get_result. - Decision. Your backend compares the confidence with the threshold from the same response, saves the verdict, and tells your app.
Sequence
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.
| Mode | Where it runs | Your effort | Look and feel | Region | How you get the result |
|---|---|---|---|---|---|
| RAW flash (this guide) | Your own page, in a browser or an in-app WebView, with FinAuth's JS SDK | Medium to high: your UI, WebRTC, state | Your branding. The screen flashes colours | Singapore only | Your backend reads raw/get_result. No callback |
| H5 lite | FinAuth's hosted page, in a browser or WebView | Low | FinAuth's page, themed in the console. A desktop shows a QR code by default | api-global, labelled "Indonesia region" Conflict · OI-2 | Signed notify_url callback, then get_result |
| Premium SDK | Native SDK inside your Android app | High: SDK, model file, app approval, ARM only | Native guided screens | Singapore or Indonesia | Your 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_idand averify_id. H5 lite takes an optionalscene_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. Usehttps.
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
| Item | Example | Notes |
|---|---|---|
| App ID for Mobile Client (Premium SDK only) | com.example.kyc | Must 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 characters | The 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 enable | AnyDoc, RAW flash | Ask 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/notify | You send it in each get_token call. Ports 443, 5000, 16003, 8883 or 8028 only. https://example.com:8443/… will not work. |
| A contact | name and email | For 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:
| Part | Test vector value | Meaning |
|---|---|---|
| MAC (bytes 1 to 20) | afcf4cbb999de85d975a697bc7f7091672e5cfe9 (hex) | HMAC-SHA1 of the raw string, keyed with the secret. 20 raw bytes. |
a | test-api-key-not-real | The api_key of the product you call. Readable by anyone. |
b | 1800000100 | Expiry. Unix seconds. c + TTL. |
c | 1800000000 | Now. Unix seconds. |
d | 1234567892 | Random. 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
| Host | Region | Used for in this guide | Notes |
|---|---|---|---|
https://api-sgp.yljz.com | Singapore | AnyDoc, RAW flash get_token and get_result, Premium server APIs | The default host for this guide. |
wss://rtc-sgp.yljz.com | Singapore | RAW JS SDK WebRTC signalling | Pass it as host. Required since SDK 2.0.3. It is the only RTC host documented (OI-3). |
https://api-global.yljz.com | Labelled "Indonesia region" Conflict · OI-2 | H5 lite get_token, do, get_result | A "global" host labelled as Indonesia. Its physical region and data residency are unconfirmed. |
https://api-idn.yljz.com | Indonesia | Premium 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.com | Mexico | — | Mexico INE only. Not covered. |
https://api.yljz.com | Not 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_resultareGETrequests with query parameters. - HTTPS only.
Two auth styles
The product decides the auth style, not you.
| Style | How | Products in this guide |
|---|---|---|
| A. Raw key and secret | Send api_key and api_secret as form fields, or as query parameters on get_result. No sign. | RAW flash, H5 lite |
| B. HMAC sign | Send 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.jpgResponses and errors
request_idcomes back on every call. Log it every time. FinAuth support needs it.time_usedis FinAuth's processing time, in milliseconds.- Your
biz_nois echoed back where you sent one.
The error field's name depends on the product. Read both.
| Field | Products |
|---|---|
error | Premium server APIs, Compare, OCR, and most others |
error_message | H5 lite, RAW flash, AnyDoc |
A 413 body is plain text (Request Entity Too Large), not JSON. Check the status before you parse.
Size limits
| Request | Limit | Over the limit |
|---|---|---|
Face images (Premium, RAW get_token) | 4096 px | 400 INVALID_IMAGE_SIZE |
Premium verify | 10 MB | 413, plain text |
RAW flash get_token | 20 MB | 413, plain text |
AnyDoc file | 9 MB. jpg, jpeg, png, gif, webp or PDF | 400 FILE_TOO_LARGE, FILE_UNSUPPORTED_FORMAT |
OCR oversea_ocr image | 5 MB, 5000 × 5000 px | 400 INVALID_IMAGE_SIZE |
RAW still raw/verify references (not covered) | 2 MB per image | 413 |
H5 lite get_token | Not documented by FinAuth for this endpoint. The general limit is 10 MB. | 413 |
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
- 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) 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)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 = 1234567892raw=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)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) = afcf4cbb999de85d975a697bc7f7091672e5cfe9sign= 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==- Send
signandsign_version=hmac_sha1with the request's other fields asmultipart/form-data.Send
signas it is. A multipart field needs no URL encoding.
Prove your helper against the test vector before you call FinAuth.
Anatomy of a sign
api_key, travels in clear inside the Base64.Notes
- Single use. The Premium authentication page allows
b = 0for 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 saysc"must be smaller than expire_time"; another says "not older than" Conflict · OI-38.c= now andb= now + 100 satisfies both. - Clock. FinAuth's tolerance for clock skew is not documented. A wrong server clock likely shows up as
EXPIRED_SIGNInferred. Keep NTP on (GL-7). - Vendor samples. Do not copy them. The Python sample is Python 2. The Objective-C sample formats
current_timewith%fand 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
test-api-key-not-realtest-api-secret-not-real180000000018000001001234567892a=test-api-key-not-real&b=1800000100&c=1800000000&d=1234567892afcf4cbb999de85d975a697bc7f7091672e5cfe9r89Mu5md6F2XWml7x/cJFnLlz+lhPXRlc3QtYXBpLWtleS1ub3QtcmVhbCZiPTE4MDAwMDAxMDAmYz0xODAwMDAwMDAwJmQ9MTIzNDU2Nzg5Mg==d was chosen so that the sign contains +, / and ==. That catches URL-safe Base64 and stripped padding.Check in this order:
- Your raw string is exactly 62 characters and equals the one above.
- Your MAC hex equals the one above.
- 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 sign | Cause |
|---|---|
| 136 characters | You Base64-encoded the 40-character hex digest instead of the 20 raw bytes. |
| 84 characters | You 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 YT10 | Raw first, MAC second. Put the MAC first. |
| 112 characters but different | Wrong 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')); // trueSign 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.
notify_url)test-api-secret-not-real{"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"}}116cb19ca5db0b25428e6064e86c3518bedac8cedata, 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 hash | sha1(secret + it) | Matches? |
|---|---|---|
data as received (161 characters) | 116cb19ca5db0b25428e6064e86c3518bedac8ce | Yes |
& escaped as & (166 characters) | 41c33a6c24b7b6490baf659bfa91b04281754e6b | No |
| Pretty-printed with 2-space indent | 8a88b955d6d827549f9a0ce474a8b5dd2f46488f | No |
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
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);
}.NET 8. Uses only the base class library: HMACSHA1, SHA1, Convert, RandomNumberGenerator and CryptographicOperations.FixedTimeEquals.
// Copy and adapt. Not run. .NET 8, BCL only (no NuGet packages). Backend only.
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
public static class FinAuthSigner
{
// Auth style B. Builds sign from fixed values, so you can test it against the vector.
public static string SignWith(string apiKey, string apiSecret, long expire, long now, long random)
{
string rawText = string.Create(CultureInfo.InvariantCulture, $"a={apiKey}&b={expire}&c={now}&d={random}");
byte[] raw = Encoding.UTF8.GetBytes(rawText); // this order, no spaces
byte[] mac = HMACSHA1.HashData(Encoding.UTF8.GetBytes(apiSecret), raw); // 20 raw bytes, not hex
byte[] signed = new byte[mac.Length + raw.Length];
mac.CopyTo(signed, 0); // MAC first
raw.CopyTo(signed, mac.Length);
return Convert.ToBase64String(signed); // standard Base64: keeps + / =
}
// Auth style B. A new sign for every request.
public static string Sign(string apiKey, string apiSecret, int ttlSeconds = 100)
{
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); // seconds, not milliseconds
long random = RandomNumberGenerator.GetInt32(int.MaxValue); // at most 10 digits
return SignWith(apiKey, apiSecret, now + ttlSeconds, now, random);
}
// H5 lite notify_url. sign = sha1(api_secret + data), lowercase hex. Pass data exactly as received.
public static bool VerifyCallbackSign(string apiSecret, string rawData, string sign)
{
byte[] hash = SHA1.HashData(Encoding.UTF8.GetBytes(apiSecret + rawData));
string expected = Convert.ToHexString(hash).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(sign));
}
}Python 3, standard library only. secrets is the cryptographic random source. hmac.compare_digest compares in constant time. The vendor's own sample is Python 2: do not use it.
# Copy and adapt. Not run. Python 3.6+, standard library only. Backend only.
import base64
import hashlib
import hmac
import secrets
import time
def sign_with(api_key: str, api_secret: str, expire: int, now: int, rand: int) -> str:
"""Auth style B. Builds sign from fixed values, so you can test it against the vector."""
raw = f"a={api_key}&b={expire}&c={now}&d={rand}".encode("utf-8") # this order, no spaces
mac = hmac.new(api_secret.encode("utf-8"), raw, hashlib.sha1).digest() # 20 raw bytes, not hex
return base64.b64encode(mac + raw).decode("ascii") # standard Base64: keeps + / =
def finauth_sign(api_key: str, api_secret: str, ttl_seconds: int = 100) -> str:
"""Auth style B. A new sign for every request."""
now = int(time.time()) # seconds
return sign_with(api_key, api_secret, now + ttl_seconds, now, secrets.randbelow(10**10))
def verify_callback_sign(api_secret: str, raw_data: str, sign: str) -> bool:
"""H5 lite notify_url. sha1(api_secret + data), lowercase hex. Pass data exactly as received."""
expected = hashlib.sha1((api_secret + raw_data).encode("utf-8")).hexdigest()
return hmac.compare_digest(expected, sign)Reference backend shape
This is not an existing Bastion API. It is the smallest backend that keeps every key off the client.
| Endpoint | What it does | Returns to the app |
|---|---|---|
POST /kyc/id-check | Takes 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/liveness | Loads the accepted ID image for this session. Calls raw/get_token with it as image_ref1. | The token only. |
GET /kyc/liveness/{id}/result | Calls 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}` };
}ASP.NET Core on .NET 8, with IHttpClientFactory, MultipartFormDataContent and System.Text.Json. No NuGet packages. Errors surface as FinAuthException with the HTTP status, FinAuth's error code and the path, never the full URI.
// Copy and adapt. Suggested shape (REC-G1), not an existing Bastion API. Not run.
// .NET 8, BCL and ASP.NET Core only. Register in Program.cs:
// builder.Services.AddHttpClient("finauth", c => c.Timeout = TimeSpan.FromSeconds(15));
// builder.Services.Configure<FinAuthOptions>(builder.Configuration.GetSection("FinAuth")); // values from a vault
// builder.Services.AddSingleton<FinAuthClient>();
using System.Net;
using System.Text.Json;
using Microsoft.Extensions.Options;
public sealed class FinAuthKeys { public string ApiKey { get; set; } = ""; public string ApiSecret { get; set; } = ""; }
public sealed class FinAuthOptions
{
public FinAuthKeys AnyDoc { get; set; } = new();
public FinAuthKeys Liveness { get; set; } = new();
}
public sealed class FinAuthException(string message, int? httpStatus = null, string? error = null, Exception? inner = null)
: Exception(message, inner)
{
public int? HttpStatus { get; } = httpStatus;
public string? Error { get; } = error;
}
public sealed class FinAuthClient(IHttpClientFactory httpFactory, IOptions<FinAuthOptions> options)
{
private const string Sgp = "https://api-sgp.yljz.com";
private const string PhIdsAppId = "builtin-laCUS0pQTRGOrEas0l";
private readonly FinAuthOptions _o = options.Value;
// 1. ID document check. Auth style B (sign). Key: the AnyDoc key.
public Task<JsonElement> AnyDocAsync(byte[] idImage, CancellationToken ct) =>
SendAsync(new HttpRequestMessage(HttpMethod.Post, $"{Sgp}/finauth/v1/anydoc")
{
Content = new MultipartFormDataContent
{
{ new StringContent(FinAuthSigner.Sign(_o.AnyDoc.ApiKey, _o.AnyDoc.ApiSecret)), "sign" },
{ new StringContent("hmac_sha1"), "sign_version" },
{ new StringContent(PhIdsAppId), "app_id" },
{ new ByteArrayContent(idImage), "file", "id.jpg" },
},
}, ct);
// 2. Liveness token. Auth style A (raw key and secret). Key: "liveness detection and deepfake detection".
public Task<JsonElement> RawGetTokenAsync(byte[] idImage, string bizNo, string uuid, CancellationToken ct) =>
SendAsync(new HttpRequestMessage(HttpMethod.Post, $"{Sgp}/finauth/lite/raw/get_token")
{
Content = new MultipartFormDataContent
{
{ new StringContent(_o.Liveness.ApiKey), "api_key" },
{ new StringContent(_o.Liveness.ApiSecret), "api_secret" },
{ new ByteArrayContent(idImage), "image_ref1", "id.jpg" }, // exactly this name
{ new StringContent("0"), "comparison_type" }, // RAW flash always compares
{ new StringContent("raw_flash"), "procedure_type" },
{ new StringContent("6"), "color_number" },
{ new StringContent("5"), "max_upload_times" },
{ new StringContent("60"), "life_time" }, // minutes
{ new StringContent("0"), "fail_when_ref_multiple_faces" }, // ID cards can carry a second portrait
{ new StringContent(uuid), "uuid" }, // stable pseudonymous user ID
{ new StringContent(bizNo), "biz_no" }, // unique per attempt
},
}, ct);
// 3. Result. A GET with the secret in the query string: never log this URI.
public Task<JsonElement> RawGetResultAsync(string token, CancellationToken ct) =>
SendAsync(new HttpRequestMessage(HttpMethod.Get,
$"{Sgp}/finauth/lite/raw/get_result?api_key={Uri.EscapeDataString(_o.Liveness.ApiKey)}" +
$"&api_secret={Uri.EscapeDataString(_o.Liveness.ApiSecret)}&token={Uri.EscapeDataString(token)}&return_image=0"), ct);
private async Task<JsonElement> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
using (request)
{
string path = request.RequestUri?.AbsolutePath ?? ""; // log the path, never the full URI
try
{
using HttpResponseMessage res = await httpFactory.CreateClient("finauth").SendAsync(request, ct);
string body = await res.Content.ReadAsStringAsync(ct);
int status = (int)res.StatusCode;
if (res.StatusCode == HttpStatusCode.RequestEntityTooLarge)
throw new FinAuthException($"FinAuth 413 on {path}: file too large", status, body.Trim()); // plain text
JsonElement json;
try { json = JsonSerializer.Deserialize<JsonElement>(body); }
catch (JsonException ex) { throw new FinAuthException($"FinAuth {status} on {path}: body is not JSON", status, null, ex); }
string? error = Str(json, "error") ?? Str(json, "error_message"); // name varies by product
if (!res.IsSuccessStatusCode)
throw new FinAuthException($"FinAuth {status} on {path}: {error} (request_id {Str(json, "request_id")})", status, error);
return json;
}
catch (HttpRequestException ex)
{
throw new FinAuthException($"FinAuth unreachable: {path}", null, null, ex);
}
catch (TaskCanceledException ex) when (!ct.IsCancellationRequested)
{
throw new FinAuthException($"FinAuth timed out: {path}", null, null, ex);
}
}
}
private static string? Str(JsonElement e, string name) =>
e.ValueKind == JsonValueKind.Object && e.TryGetProperty(name, out JsonElement p) && p.ValueKind == JsonValueKind.String
? p.GetString() : null;
}
public enum Outcome { Verified, NotMatched, LivenessFailed, CouldntFinish }
public static class FinAuthDecision
{
// Default deny (REC-G3). level is "1e-4" or "1e-5". The threshold comes from the same response.
public static (Outcome Outcome, string Reason) Decide(JsonElement r, string level)
{
string status = Text(r, "status").ToUpperInvariant(); // the docs mix "Failed" and "FAILED"
if (status != "OK") return (Outcome.CouldntFinish, $"status {status}");
string liveness = Text(r, "liveness_result", "result").ToUpperInvariant();
if (liveness != "PASS") return (Outcome.LivenessFailed, $"liveness {liveness}");
string error = Text(r, "verify_result", "error_message");
if (error.Length > 0) return (Outcome.CouldntFinish, $"verify {error}");
double? confidence = Number(r, "verify_result", "result_ref1", "confidence");
double? threshold = Number(r, "verify_result", "result_ref1", "thresholds", level);
if (confidence is null || threshold is null) return (Outcome.CouldntFinish, "no confidence or threshold");
return confidence > threshold
? (Outcome.Verified, $"{confidence} > {threshold} at {level}")
: (Outcome.NotMatched, $"{confidence} <= {threshold} at {level}");
}
private static JsonElement? At(JsonElement e, params string[] path)
{
foreach (string name in path)
{
if (e.ValueKind != JsonValueKind.Object || !e.TryGetProperty(name, out e)) return null;
}
return e;
}
private static string Text(JsonElement r, params string[] path) =>
At(r, path) is { ValueKind: JsonValueKind.String } v ? v.GetString() ?? "" : "";
private static double? Number(JsonElement r, params string[] path) =>
At(r, path) is { ValueKind: JsonValueKind.Number } v ? v.GetDouble() : null;
}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)| Field | Required | Value to use | Notes |
|---|---|---|---|
sign | Yes | computed per request | Signed with the AnyDoc key. See How to sign a request. |
sign_version | Yes | hmac_sha1 | |
file | Yes | the ID image | At most 9 MB. jpg, jpeg, png, gif, webp or PDF. |
app_id | Yes | builtin-laCUS0pQTRGOrEas0l | 13 Philippine document types. An unknown app_id gets 400 BAD_ARGUMENTS. |
inputs | No | leave it out | A 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):
cardTypeis 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)| Parameter | Required | Value to use | Notes |
|---|---|---|---|
api_key, api_secret | Yes | the liveness key pair | From configuration, on your backend. |
image_ref1 | Yes | the accepted ID image | The first image field must be named exactly image_ref1. Otherwise: 400 MISSING_ARGUMENTS:image_ref1 (TS-18). |
comparison_type | Yes | 0 | The only value. Others get 400. A key without permission gets 403 Denied. |
procedure_type | No | raw_flash | The only value. |
color_number | No | 6 | 4, 6 or 8. 6 is recommended. |
max_upload_times | Yes | 5 | 2 to 5. 5 is recommended. |
life_time | Yes | 60 | Minutes, 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. |
uuid | Yes | a stable pseudonymous user ID | Up 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_no | No | yours, unique per attempt | Up to 128 bytes. Echoed back. Log it. |
fail_when_ref_multiple_faces | No | 0 | The 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). |
language | No | leave it out | 0 English (default), 1 Chinese. |
liveness_preferences | No | leave it out | none is the only value and the default. |
multi_oriented_detection, return_multifaces_tag, face_occlusion_detection | No | leave them out | 0/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 holdsdist/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 saysRctVideo, 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 callslocalhost/undefined/raw_signaland fails with4000 NETWORK_ERROR(TS-17).videoWrapperis an element id string, not a DOM element. The SDK looks the element up itself Inferred.
| Option | What it is | Value to use |
|---|---|---|
videoWrapper | Id of the element that holds the video | 'liveness-video' |
host | WebRTC signalling host | 'wss://rtc-sgp.yljz.com' |
token | From your backend | Never call get_token from the page |
width, height | Video size | '100%', 'auto' |
videoPermsTimeout, webSocketTimeout, WebRTCTimeout | Timeouts, in ms | 6000 each (as in the POCs) |
colorListener(color, status, index, sum) | The current flash colour. status is RESET, DONE or COLORING | Paint the page background with color |
tipListener(tipCode, tipTxt) | Guidance for the user | Map tipCode to your own text (below) |
statusListener(code) | Status changes | Update your screen |
resultCallback(isSuccess, resultCode, resultMessage) | End of the capture | On 1000, ask your backend for the result |
autoplayFailCallback(play) | The browser blocked autoplay | Show 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)
| Code | Meaning |
|---|---|
INIT_LOADING | Starting up. |
INIT_LOADING_OVER | Started. |
LOOK_MIRROR | The user frames their face. Tips appear. |
SHOW_COLOR | The screen flashes colours. |
RESULT_LOADING | Sending the capture. |
RESULT_LOADING_OVER | Sent. |
DISCONNECTED | The 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.
| tipCode | Likely meaning | Show the user |
|---|---|---|
NoFaceFound | No face in view | "Put your face in the frame." |
InvalidFaceYaw | Head turned left or right | "Look straight at the screen." |
InvalidFacePitch | Head tilted up or down | "Hold your phone at eye level." |
InvalidFaceEyeOcclusion | Eyes covered | "Make sure we can see your eyes. Take off sunglasses." |
InvalidFaceMouth | Mouth covered | "Uncover your mouth." |
TooFarAwayFromCamera | Too far | "Move closer." |
TooCloseFromCamera | Too close | "Move back a little." |
InvalidFaceOutOfImage | Face partly out of frame | "Keep your whole face in the frame." |
InvalidFaceBrightness | Too dark or too bright | "Face a light. Avoid bright light behind you." |
FaceOK | Good position | "Hold still." |
Result codes (resultCallback)
| Code | Message | Meaning | What you do |
|---|---|---|---|
1000 | SUCCESS | The capture finished. Not verified. | Show "Checking your result…". Ask your backend. |
2000 | DISCONNECTED | The connection dropped. | Get a new token and retry. |
3000 | TIMEOUT | 30 seconds passed after connecting. | Retry with a new token. |
4000 | SUPPORT_ERROR, PERMISSIONS_ERROR, NETWORK_ERROR | Browser not supported, camera denied, or no data for 6 seconds. | Set host. Grant the camera. Check the network. Offer another browser. |
5000 | TOKEN_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)| Parameter | Value to use | Notes |
|---|---|---|
api_key, api_secret | the liveness key pair | In the query string. |
token | from raw/get_token | |
return_image | 0 | 1 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 field | Meaning |
|---|---|
status | NOT_STARTED, PROCESSING, OK or FAILED. OK means finished, not passed. FAILED means the 30-second framing step failed. |
liveness_result.result | PASS or FAIL. |
liveness_result.score.flash_attack, living_attack | Attack scores. No thresholds are documented (OI-20). Log them. Do not decide on them. |
verify_result.error_message | null when the comparison ran. Otherwise NO_FACE_FOUND, DATA_SOURCE_ERROR (stop and contact FinAuth) or INTERNAL_ERROR (retry later). |
verify_result.result_ref1.confidence | Similarity, 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_2 | Only 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
Whether get_result can still say PROCESSING right after 1000 is not documented. These steps stay inside the 3-read limit either way.
- Read once, right after
resultCallback(1000). - While
statusisNOT_STARTEDorPROCESSING, wait about 3 seconds and read again. - Never read more than 3 times. After the third read, show "Couldn't finish".
- 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.
statusisOK. Otherwise: couldn't finish.liveness_result.resultisPASS. Otherwise: liveness failed.verify_result.error_messageisnull. Otherwise: couldn't finish.result_ref1.confidence>result_ref1.thresholds[level], both from the same response. Otherwise: not matched.- 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
Get a token: get_token
POST https://api-global.yljz.com/finauth/lite/get_token (auth style A, backend only)| Parameter | Value to use | Why |
|---|---|---|
api_key, api_secret | the H5 lite key pair | Backend only. |
fmp_mode | 0 | The default 1 gives no anti-spoof verdict: PASS then does not mean "not a spoof" (DISC-12). Always send 0. |
comparison_type | 0 | 0 = liveness and comparison with image_ref1. -1 = liveness only. This flow compares with the accepted ID. |
image_ref1 | the accepted ID image | Required when comparison_type is 0. |
fail_when_ref_multiple_faces | 0 | Uses the largest face. ID cards can carry a second portrait Inferred. |
procedure_type | flash | The default. Also active_flash, distance and still. |
procedure_priority | still | Falls back to a still video when the browser has no WebRTC. Already the default for flash. |
language | 4 for Filipino | 0 English (default), 1 Chinese, 2 Indonesian, 3 Thai, 4 Filipino, 5 Vietnamese, 6 Spanish. |
biz_no | yours, unique per attempt | Required. Up to 128 bytes. |
uuid | a stable pseudonymous user ID | Up to 512 bytes. The docs' wording on when it is required is unclear. |
return_url | your page | Required. A browser redirect: for the screen only. |
notify_url | your HTTPS endpoint | Required. Ports 443, 5000, 16003, 8883 or 8028 only. |
scene_id | your console H5 scene | Optional. Sets the page's look and the flow timeout. A scene that does not exist gets 400 BAD_ARGUMENTS. |
orientation_mode | 1 on desktops and kiosks | 0 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_reason | 1 | Adds fail_reason: NETWORK_ERROR, PERMISSIONS_ERROR or SUPPORT_ERROR. |
action_http_method | POST or GET | How 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=OKmeans finished, not passed. liveness_result.resultis a real anti-spoof verdict only withfmp_mode=0.liveness_result.face_genuinenessadds synthetic-face, mask and screen-replay scores. A confidence above its threshold means an attack.- H5 lite adds three statuses:
WEBRTC_UNSUPPORTED,CANCELLEDandTIMEOUT. See Error codes.
Billing
| comparison_type | Billed when | Not billed |
|---|---|---|
0 | verify_result.error_message is null or NO_FACE_FOUND | Other error_message values Inferred. Cancelled or timed out with no verify_result Inferred · OI-16 |
-1 | The flow ends with status=OK | Other 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.
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
| Source | Arrives as | Signed? | What you do |
|---|---|---|---|
H5 lite notify_url | Server-to-server POST: data (a JSON string) and sign | Yes: sha1(api_secret + data), lowercase hex | Verify the sign on the raw data. Dedupe by biz_id. Confirm with get_result before you act (SEC-REV-5). |
H5 lite return_url | Browser redirect: a form POST, or GET with ?biz_id= | No. Whether the POST is signed is unknown Unconfirmed · OI-14 | For the screen only. Show "Checking your result…". Never decide from it. |
RAW JS SDK resultCallback | A JavaScript call in your page | No. Client-side only | Ask your backend to read the result. |
Premium SDK 1000 LIVENESS_FINISH | A callback in your app | No. Client-side only | Ask your backend to call verify. |
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:
signequalssha1(api_secret + data), computed ondataexactly as received, compared in constant time.biz_info.biz_idmatches a session you created.- You have not processed this
biz_idbefore. 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 returnsDATA_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_bestor 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.
| Product | Endpoints | Auth | Console key name | Scene or app_id | Region | Billed when |
|---|---|---|---|---|---|---|
| AnyDoc | POST /finauth/v1/anydoc | B | The AnyDoc key; name not in the sources (SEC-9 / SEC-10 Inferred) | app_id builtin-laCUS0pQTRGOrEas0l | Singapore | HTTP 200 and pages not empty: pages × unit price |
| RAW flash | POST /finauth/lite/raw/get_token, wss://rtc-sgp.yljz.com, GET /finauth/lite/raw/get_result | A | "liveness detection and deepfake detection" (SEC-3 / SEC-4) | None | Singapore | Not documented by FinAuth (OI-17). get_token errors are not billed |
| H5 lite | POST /finauth/lite/get_token, GET /finauth/lite/do, GET /finauth/lite/get_result, callbacks | A | Not identified in the sources (OI-33) | scene_id (optional) | api-global Conflict · OI-2 | comparison_type=0: error_message null or NO_FACE_FOUND. -1: status=OK |
| Premium SDK | POST /finauth/v5/sdk/get_biz_token, POST /finauth/v5/sdk/verify | B | Token: "liveness detection and deepfake detection" (SEC-3 / SEC-4). Verify: "1:1 face comparison" in the POC (SEC-5 / SEC-6) Inferred | liveness_id, verify_id | Singapore or Indonesia | verify: 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
CAMERAandINTERNET. Ask forCAMERAat runtime. - In
WebChromeClient.onPermissionRequest, grant onlyRESOURCE_VIDEO_CAPTURE, only to the expected origin, and only when the app holdsCAMERA. 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
mediaTypesRequiringUserActionForPlaybackto none. - Add
NSCameraUsageDescriptiontoInfo.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.jsfrom your own origin. - H5 lite in an iframe: put
allow="camera"on that iframe only, and limit the CSPframe-srcto 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.cominconnect-src. Not in FinAuth docs; not tested. The full list of hosts the SDK contacts is not documented by FinAuth. Whenhostwas missing, the POC saw it call atrack_logpath as well asraw_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.comWebRTC 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.
| Platform | Environment | WebRTC support | If it fails |
|---|---|---|---|
| iOS | Safari 11+ (iOS 11.1.2+) | 100 % | — |
| iOS | In-app WKWebView, iOS 14.3+ | 93 % | H5 lite: still video. RAW flash: suggest Safari. |
| iOS | QQ, Baidu, Chrome | 100 % | — |
| Android | In-app WebView, Android 7+ | 95 % | H5 lite: still video. RAW flash: suggest Chrome. |
| Android | Preinstalled browsers: Huawei, Vivo, Honor, Samsung | about 80 % | Suggest Chrome. |
| Android | Preinstalled browsers: OnePlus, Xiaomi, OPPO | about 40 % | Suggest Chrome. |
| Android | QQ / Baidu / UC | 100 % / 40 % / 20 % | Suggest Chrome. |
| HarmonyOS | NEXT 5.0+ | 100 % | — |
| iOS, HarmonyOS | UC browser | Not supported | Ask the user to switch browser. |
| Desktop | Any | Not covered by the table | Test your target browsers. |
Cross-platform frameworks
The Android and iOS rules above still apply underneath each framework.
| Framework | Where the RAW page runs | Camera permission | Gotchas |
|---|---|---|---|
| Capacitor / Ionic | In the app's own web layer Inferred, or your HTTPS page | Android 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 Native | Your HTTPS page in react-native-webview | Android 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. |
| Flutter | Your HTTPS page in webview_flutter or flutter_inappwebview | Android 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
| State | When | Show |
|---|---|---|
| Not started | No ID yet. | Your consent and privacy notice, then "Scan your ID" and "Upload your ID". List the ID types you accept. |
| Checking ID | Your backend is calling AnyDoc. | "Checking your ID…". Disable the buttons. |
| ID not accepted | The 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 liveness | The ID was accepted. | The preparation tips (below) and a "Start" button. |
| Liveness running | start() was called. | The video, live tips from tipListener, the colour flashes and a Cancel button. |
| Checking result | resultCallback(1000). Your backend is reading the result. | "Checking your result…". Never "Verified" yet. |
| Verified | The decision is verified. | "You're verified." Continue. |
| Not matched | Confidence at or below the threshold. | "Your face didn't match your ID." One retry, then another path. |
| Liveness failed | liveness_result.result is FAIL. | "We couldn't confirm it's you, live." The preparation tips. One retry, then another path. |
| Couldn't finish | SDK 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
tipCodeto your own text. See Tip codes. - Get the token when the user taps Start. Not on page load. Example: a token with
life_time=60fetched at 10:00 and used at 11:05 is likely to fail with5000 TOKEN_EXPIREDInferred. - 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
FAILfor 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=OKmeans 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.
404 API_NOT_FOUND: wrong host or path.403 AUTHENTICATION_ERROR: the sign, or the key and secret, do not check out.403 AUTHORIZATION_ERROR:<reason>: entitlement, IP, quota or an expired sign.403 CONCURRENCY_LIMIT_EXCEEDED: over your QPS.400 MISSING_ARGUMENTSorBAD_ARGUMENTS: a field is missing or invalid.400image or file errors.413: the request is too large.200with a business result:status,result_codeorerror_messagein the body.
How to debug a rejected request
- Run the test vectors through your code. If raw or the sign differs, fix signing first.
- 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.
- Check that the key belongs to the product you are calling. See Products, keys and scenes.
- Check your server clock. Keep NTP on.
- Log
request_id,biz_noand, for H5 lite,biz_idfor every call. Never log the secret, images or fullget_resultURLs. - Send FinAuth the
request_id, the time, the endpoint and the error.
Symptoms
| Symptom | Likely cause | Fix |
|---|---|---|
| The camera does not open in your app | Permission 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 website | No 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 TIMEOUT | Network 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 fails | Poor 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 person | A 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_FOUND | Wrong lookup ID, wrong host, or older than 1 day. | biz_id for H5 lite, token for RAW flash. Read promptly. |
DATA_DESTROYED | More than 3 reads, or older than 1 day. | Persist on the first read. Start a new session. |
| Billed, but you have no result | The 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_SIGN | Wrong 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_REACHED | The 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 product | Wrong 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_EXCEEDED | Over QPS: 2 on test keys. | Back off and queue. |
413 Request Entity Too Large, and the body is not JSON | The file is over the limit. | Compress. See Size limits. |
400 MISSING_ARGUMENTS:image_ref1 | The reference field is misnamed. | Name it exactly image_ref1. |
RAW SDK 4000 NETWORK_ERROR | host is missing. 2.0.3 has no default. | Set host: 'wss://rtc-sgp.yljz.com'. |
SDK says 1000, but the user is not verified | 1000 means finished. The server said not matched or failed. | Show the server's verdict, never the SDK code. |
The notify_url callback never arrives | Port not allowed. Firewall. | Use port 443. Open the firewall. Fall back to get_result. |
| OCR returns empty fields | The 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_FAILED | An x86 emulator. | Use a physical ARM device. |
Premium: 1019 DATA_UPLOAD_FAIL | Unstable 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 Chinese | SDK 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
| Symptom | Likely cause | Fix |
|---|---|---|
| First POC app: a fake ID passes the ID check | The 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 liveness | get_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 document | The 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)
| Code | HTTP | Where | Meaning | Fix | Billed |
|---|---|---|---|---|---|
AUTHENTICATION_ERROR | 403 | All | Bad 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_PERMISSION | 403 | AnyDoc | The app_id is not enabled on your key. | Ask Finlaris. | ❌ |
AUTHORIZATION_ERROR:INVALID_SIGN | 403 | Style B products | The 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_SIGN | 403 | Style B products | b has passed, or the clock is off Inferred. | NTP. A TTL of about 100 seconds. | ❌ |
AUTHORIZATION_ERROR: Denied | 403 | Any product | The 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_PERMISSION | 403 | Per product | The product is not provisioned. | Ask Finlaris. | ❌ |
AUTHORIZATION_ERROR:LIMIT_REACHED | 403 | All | The test-key quota is used up. | Ask Finlaris. | ❌ |
AUTHORIZATION_ERROR:IP_NOT_ALLOWED | 403 | All | Your server's IP is not allowed. | Ask Finlaris to add it. | ❌ |
AUTHORIZATION_ERROR:API_KEY_BE_DISCONTINUED | 403 | All | The key was disabled. | Get a new key. | ❌ |
AUTHORIZATION_ERROR:MORE_RETRY_TIMES | 403 | Premium, RAW flash | Too many retries. | Back off. | ❌ |
AUTHORIZATION_ERROR:5001, 5002, 5007 | 403 | OCR | Numeric reasons. Not documented by FinAuth. | Ask FinAuth. | ❌ |
CONCURRENCY_LIMIT_EXCEEDED | 403 | All | Over the QPS limit. Test keys: 2. | Back off and queue. | ❌ |
DATA_DESTROYED | 403 | RAW get_result | 3 reads used, or older than 1 day. | Persist on the first read. | ❌ |
Request, image and file (400)
| Code | HTTP | Where | Meaning | Fix | Billed |
|---|---|---|---|---|---|
MISSING_ARGUMENTS:<key> | 400 | All | A field is missing. Seen when image_ref1 was misnamed. | Send the exact field name. | ❌ |
BAD_ARGUMENTS:<key> | 400 | All | An 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_FORMAT | 400 | Most | Not JPG or PNG. H5 lite spells it …FORMA. | Send JPG or PNG. | ❌ |
INVALID_IMAGE_SIZE | 400 | Most | Over 4096 px (OCR: 5000 px). | Resize. | ❌ |
NO_FACE_FOUND | 400 | Premium, RAW and H5 get_token | No face in an image. | Use a better image. | ❌ (but H5 verify_result NO_FACE_FOUND is billed) |
MULTIPLE_FACES | 400 | Most | More 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_QUALITY | 400 | Premium, Compare | Image quality below the threshold. | Use a better image. | ❌ |
INVALID_TOKEN | 400 | H5 lite | Bad token. | Get a new token. | ❌ |
RESULT_NOT_FOUND | 400 | H5 and RAW get_result | Wrong lookup ID. | biz_id (H5 lite), token (RAW). | ❌ |
VIDEO_FACE_OCCLUDE | 400 | H5 get_result | The face was covered. | Ask the user to uncover it. | ❌ |
FACELIVE_DATA_ERROR, FACELIVE_DATA_BIZ_TOKEN_NOT_MATCH | 400 | Premium verify | Bad SDK data, or the wrong token. | Use the matching token. Do not modify the data. | ❌ |
DATA_APPKEY_NOT_MATCH | 400 | Premium verify | A different key from the one that made the token. | Use the same key for the token and verify. | ❌ |
KEY_NOT_FOUND | 400 | Premium verify, AnyDoc | Encryption 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_FILES | 400 | AnyDoc | File problems. | One file, at most 9 MB, jpg, jpeg, png, gif, webp or PDF. | ❌ |
ID_CARD_NOT_FOUND | 400 | OCR | No card found. | Retake the photo. | ❌ |
Routing, size and server
| Code | HTTP | Where | Meaning | Fix | Billed |
|---|---|---|---|---|---|
API_NOT_FOUND | 404 | All | Wrong host or path. | Check the host and region. | ❌ |
Request Entity Too Large (plain text) | 413 | All | Over the size limit. | Compress. See Size limits. | ❌ |
INTERNAL_ERROR | 500 | All | Server error. | Retry with backoff, then contact support with the request_id. | ❌ |
Result values in get_result (H5 lite and RAW flash)
| Value | Where | Meaning | What you do | Billed |
|---|---|---|---|---|
NOT_STARTED | H5, RAW | The user has not begun. | Wait. Read again within the 3-read limit. | ❌ Inferred |
PROCESSING | H5, RAW | In progress. | Read again later, at most 3 reads in all. | ❌ Inferred |
WEBRTC_UNSUPPORTED | H5 | The browser has no WebRTC. | procedure_priority=still, or another browser. | Not documented |
OK | H5, RAW | Finished. Not passed. | Run the decision. | H5: see billing. RAW: not documented |
FAILED | H5, RAW | The 30-second framing step failed. | Retry with better light. | Not documented |
CANCELLED | H5 | The user left. | Offer a retry. | ❌ Inferred |
TIMEOUT | H5 | The flow timed out. | New token. Try still. | ❌ Inferred |
verify_result.error_message null | H5, RAW | The comparison ran. | Compare with the threshold. | H5 ✅. RAW: not documented |
verify_result.error_message NO_FACE_FOUND | H5, RAW | No face in the reference or the live image. | Use a better reference. | H5 ✅. RAW: not documented |
verify_result.error_message DATA_SOURCE_ERROR | H5, RAW | A data-source failure. | Stop and contact FinAuth. | Not documented |
verify_result.error_message INTERNAL_ERROR | H5, RAW | Server 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
dataas received. - Trusting
return_urlorresultCallback(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_resultURLs 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.
- 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_resultcarries 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_idorbiz_noonget_result(OI-1), theRctVideoname in the JS sample, invalid JSON in sample responses, and the AnyDoccardTypespellings (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_attackandliving_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
livenessplusverifyon 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=1is 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_bestare undecided (D-5). Whereapi-globalprocesses data is unconfirmed (OI-2, OI-36).
Products not covered
| Product | Why 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-Forgery | No key: 403 Denied (EC-8). For Philippine IDs, Finlaris points to the OCR risk flags. |
| Credit Score | Not provisioned. Out of scope (EC-15). |
| Mexico INE | Out of scope: Mexican records only. |
| Indonesia ID check | Out of scope: Indonesian records only. |
| Basic SDK (API v3) | Superseded by the Premium SDK. |
| RAW still | Out 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.
| Lesson | What happened | Fix |
|---|---|---|
| Secrets in apps | Both 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 secret | Bastion 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 75 | Casino Web and Bastion Verifier compared the confidence with 75 (DISC-9, DISC-36). | thresholds[level] from the same response (REC-G4, REC-M4). |
| Reused liveness | Casino 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_mode | The H5 lite POCs relied on PASS/FAIL without stating fmp_mode=0 (DISC-12). | Send fmp_mode=0 (REC-H2). |
| Unverified callbacks | Callback 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.
| Item | Version | Status or date | Notes |
|---|---|---|---|
| This guide | 0.4 | POC, generated 2026-11-04 | The 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 guide | 0.3 | POC, generated 2026-11-04 | API 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 guide | 0.2 | POC, generated 2026-11-04 | A POC runner section and a local gateway were added. They moved out in 0.4. |
| This guide | 0.1 | POC, generated 2026-11-04 | First version. Audience: internal. Primary flow: AnyDoc, then RAW flash, then raw/get_result. Tracks: H5 lite, Premium SDK. |
| FinAuth API | v5 | Current | Premium and server products under /finauth/v5. H5 lite and RAW flash under /finauth/lite. AnyDoc under /finauth/v1. |
| RAW JS SDK | 2.0.3 | 2025-08-22 | host is required; there is no default. |
| Premium SDK (Android) | 5.9.0A | Vendor doc 2026-10-14 | The POC apps used 5.8.19.3A. Not yet tested on a device. |
| FinAuth docs site | — | Last checked 2026-11-04 | 53 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.