Face Active Liveness Detection
Proves a live person is in front of the camera: re-checks the selfie and challenge log the eKYC SDK captured on the device and returns a signed verdict.
/v3/store/ekyc/face-active-liveness/finalizeTry it- Price1 ICper request
- Served19.2Kcalls
- v1.0
- Active
Input
- file
- selfie.jpg
- challenges
- {"session_id":"b0e7c1a2-4f5d-4e6a-9b8c-7d6e5f4a3b2c","sdk":{"name":"iapp-ekyc-sdk-flutter","version":"0.1.0","platform":"android"},"started_at":1767500000000,"finished_at":1767500008000,"challenges":[{"type":"blink","issued_at":1767500000123,"completed_at":1767500001873,"passed":true},{"type":"turn_left","issued_at":1767500002000,"completed_at":1767500004100,"passed":true},{"type":"smile","issued_at":1767500004500,"completed_at":1767500006900,"passed":true}]}
OutputPOST /v3/store/ekyc/face-active-liveness/finalize
- verdict
- { passed, passive_liveness, challenge_summary, session_id, selfie_sha256, timestamp, nonce }
- signature
- hex(HMAC-SHA256(secret, canonicalJSON(verdict)))
- signature_alg
- HMAC-SHA256
- process_time
- 0.42
Request
multipart/form-data, built for you by the SDK. To call it yourself, run equivalent challenges on the device: a random selection of blink, turn left, turn right and smile, verified on live face landmarks and timed by the real wall clock.
Headers
apikeystringrequiredBody
filefilerequiredchallengesstringrequiredsession_id, sdk, started_at, finished_at, and one entry per challenge with type, issued_at, completed_at and passed, as in the panel.return_imagestringtrue to get the selfie echoed back as Base64 in selfie.The log is accepted when every type is one of the four, at least two challenges were issued and all passed, the timestamps rise strictly, each challenge took 300 ms to 30 s, the session took at most 120 s, and it finished within 5 minutes of the server's clock.
Response
A completed check is 200 and billed even when passed is false; an error response is never billed.
verdictobjectverdict.passedbooleantrue only when the log is valid and the selfie passes the iBeta Level 1 certified passive checkverdict.passive_livenessobjectThe passive check on the selfie
verdict.passive_liveness.predictstringverdict.passive_liveness.real_scorenumberverdict.passive_liveness.thresholdnumberverdict.challenge_summaryobjectA summary of the checked challenges; reasons lists why when valid is false
verdict.challenge_summary.totalintegerverdict.challenge_summary.passedintegerverdict.challenge_summary.typesarrayverdict.challenge_summary.duration_msintegerverdict.challenge_summary.validbooleanverdict.challenge_summary.reasonsarrayverdict.session_idverdict.timestampverdict.nonceverdict.selfie_sha256stringsignaturesignature_algverdict, keyed with your account's signing secretselfiereturn_image=true: filename, content_type, size, image_base64process_timenumberVerify the signature
Recompute the HMAC on your backend over the canonical form of verdict (keys sorted recursively, no insignificant whitespace, UTF-8) and compare in constant time. Then check passed, that timestamp is fresh, and, if the selfie travels separately, that its SHA-256 equals selfie_sha256.
The signature is the signature field of the response body, not an HTTP header. The key is your account's signing secret, shown on the API Keys page: one value per account, shared by all of its API keys and different from any of them. Keep it on your server; a secret shipped in an app or a web page lets anyone forge a verdict.
Pass the response body exactly as received. The signed text uses JavaScript's number format (for example 0.00005 and 3.4e-7): JSON.parse followed by JSON.stringify reproduces it, but Python's json.dumps writes 5e-05, so small real_score values would fail to verify. The Python and C# examples therefore keep each number's text from the body. All three were tested against live verdicts.
- Node.js
- Python
- C#
const crypto = require('crypto');
const sortKeysDeep = (v) =>
Array.isArray(v) ? v.map(sortKeysDeep)
: v && typeof v === 'object'
? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortKeysDeep(v[k])]))
: v;
const canonical = (o) => JSON.stringify(sortKeysDeep(o));
function verifyVerdict(responseBody, accountSecret) {
const { verdict, signature } = JSON.parse(responseBody);
const expected = crypto.createHmac('sha256', accountSecret).update(canonical(verdict)).digest('hex');
return expected.length === signature.length &&
crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(signature, 'hex'));
}
// responseBody: the finalize response as a string; accountSecret: from the API Keys page
const ok = verifyVerdict(responseBody, accountSecret);
import hashlib
import hmac
import json
class _RawNumber(str):
"""A JSON number kept exactly as the server wrote it."""
def _canonical(value):
if isinstance(value, dict):
keys = sorted(value, key=lambda k: k.encode("utf-16-be")) # JavaScript sort order
return "{" + ",".join(
json.dumps(k, ensure_ascii=False) + ":" + _canonical(value[k]) for k in keys
) + "}"
if isinstance(value, list):
return "[" + ",".join(_canonical(v) for v in value) + "]"
if isinstance(value, _RawNumber):
return str(value)
return json.dumps(value, ensure_ascii=False) # strings, true, false, null
def verify_verdict(response_body: str, account_secret: str) -> bool:
"""response_body: the finalize response exactly as received (str)."""
resp = json.loads(response_body, parse_float=_RawNumber, parse_int=_RawNumber)
canonical = _canonical(resp["verdict"])
expected = hmac.new(account_secret.encode(), canonical.encode("utf-8"), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, resp["signature"])
ok = verify_verdict(response.text, account_secret) # e.g. requests.Response.text
using System;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
public static class IappLivenessVerdict
{
// responseBody: the finalize response exactly as received. Do not deserialize it into
// a class and serialize it again; that can change numbers and key order.
// accountSecret: the Face Active Liveness Signing Secret from the iApp API Keys page.
public static bool Verify(string responseBody, string accountSecret)
{
using var doc = JsonDocument.Parse(responseBody);
var root = doc.RootElement;
var signature = root.GetProperty("signature").GetString() ?? "";
var canonical = new StringBuilder();
WriteCanonical(root.GetProperty("verdict"), canonical);
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(accountSecret));
var expected = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(canonical.ToString()))).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(signature));
}
// Canonical JSON: object keys sorted by UTF-16 code unit at every level, no whitespace,
// numbers exactly as the server wrote them.
static void WriteCanonical(JsonElement e, StringBuilder sb)
{
switch (e.ValueKind)
{
case JsonValueKind.Object:
sb.Append('{');
var first = true;
foreach (var p in e.EnumerateObject().OrderBy(p => p.Name, StringComparer.Ordinal))
{
if (!first) sb.Append(',');
first = false;
WriteString(p.Name, sb);
sb.Append(':');
WriteCanonical(p.Value, sb);
}
sb.Append('}');
break;
case JsonValueKind.Array:
sb.Append('[');
var n = 0;
foreach (var item in e.EnumerateArray())
{
if (n++ > 0) sb.Append(',');
WriteCanonical(item, sb);
}
sb.Append(']');
break;
case JsonValueKind.String:
WriteString(e.GetString()!, sb);
break;
default: // number, true, false, null
sb.Append(e.GetRawText());
break;
}
}
// Escapes a string the way JavaScript's JSON.stringify does.
static void WriteString(string s, StringBuilder sb)
{
sb.Append('"');
foreach (var c in s)
{
switch (c)
{
case '"': sb.Append("\\\""); break;
case '\\': sb.Append("\\\\"); break;
case '\b': sb.Append("\\b"); break;
case '\f': sb.Append("\\f"); break;
case '\n': sb.Append("\\n"); break;
case '\r': sb.Append("\\r"); break;
case '\t': sb.Append("\\t"); break;
default:
if (c < 0x20) sb.Append("\\u").Append(((int)c).ToString("x4"));
else sb.Append(c);
break;
}
}
sb.Append('"');
}
}
// var ok = IappLivenessVerdict.Verify(responseBody, accountSecret);
Status codes
| Code | Meaning |
|---|---|
| 400 | INVALID_CHALLENGE_LOG, INVALID_IMAGE or MISSING_FIELD, with a reasons array |
| 413 | The selfie is over 10 MB |
| 502 | UPSTREAM_UNAVAILABLE; retry later |
Details
Limits
- The session restarts when the face is lost, a second face appears, or the person changes.
Data handling
Images are processed in memory and not kept after the response; the verdict carries only the selfie's hash. The service is GDPR and PDPA compliant, and a signed verdict can be verified offline on your own backend.