Liveness API / Integration guide

Liveness, ready to integrate.

Capture on your device. Verify in the cloud. Connect silent RGB and color liveness through one compact REST interface, with module-scoped tokens and predictable usage.

REST v1Bearer tokenPNG / RGB8No client SDK required for HTTP
Your API base URLhttps://api.inspirehub.cc

Use your reachable server address or HTTPS domain. 0.0.0.0 is the server’s listening address, not a destination for remote clients.

01

Make your first connection

Get a business token from the service operator, then check your permissions and available quota. One token can grant access to one or more algorithm modules.

01Prepare on device

InspireFace eye points + validated 320 × 320 crop.

02Upload one capture

JSON metadata + lossless binary image parts.

03Read the decision

Check status, then alive. Recollect if retry.

export API_BASE='https://api.inspirehub.cc'
export API_TOKEN='YOUR_BUSINESS_TOKEN'

curl --fail-with-body "$API_BASE/v1/me" \
  -H "Authorization: Bearer $API_TOKEN"

curl --fail-with-body "$API_BASE/v1/modules" \
  -H "Authorization: Bearer $API_TOKEN"

Python examples require Python 3.10+ and httpx (pip install httpx). Set API_BASE and API_TOKEN in your environment before running them. This documentation never asks for or stores your token.

02

Choose an algorithm module

RGB

Silent liveness

A single image or a recorded sequence. The cloud runs RAW RGB inference and, for sequences, the final temporal window.

rgb_liveness
COLOR

Color liveness

One capture with white, red, green and blue illumination. The cloud computes frame differences and fuses two RAW CNN outputs.

color_liveness
POST/v1/modules/{module_id}/verify
Modulemodecapture_profileInput
rgb_livenessrgb_singlergb_single_v1One crop
rgb_livenessrgb_sequencergb_contiguous_v1Ordered frames + failure events
color_livenesscolorcolor_wrgb_v1white → red → green → blue

Availability has two controls: the module must be enabled on the server and granted to your token. GET /v1/modules returns your authorized modules and their deployment status.

03

Capture once, send a compact payload

Use multipart/form-data. The metadata part is a text field containing JSON; each image is a binary file part whose form field name exactly matches image.part. Let your HTTP library generate Content-Type and its boundary.

The crop is part of the protocol.

Before integration, implement and validate the agreed eye_crop_320_v1 preprocessing with your mobile SDK. Core accepts prepared crops; it does not detect, align or crop faces. A generic bounding-box resize is not equivalent. Keep rotation and mirroring consistent across the capture.

Image encoding

EncodingRequirements
png320 × 320, 8-bit truecolor RGB (PNG color type 2), no alpha or palette. Lossless; recommended for smaller uploads.
rgb8Exactly 307200 bytes: contiguous HWC RGB uint8 pixels. No header, channel swapping, normalization or float conversion.

Do not send JPEG, video, Base64 or float arrays. For color liveness, upload the four original crops; the cloud computes differences. Hash the exact uploaded bytes with SHA-256 and use lowercase hexadecimal. The hash checks integrity, not authenticity.

CapturePacket — all top-level fields are required

FieldContract
protocol_versionInteger 1.
capture_idString, 1–128 characters; unique within a batch. Use a new ID for each new capture.
modergb_single / rgb_sequence / color
capture_profileExact profile matching the chosen mode; see the module table.
preprocess_profileeye_crop_320_v1
completeBoolean. Set true only when the agreed capture conditions have finished. false produces a normal retry result.
framesOrdered event array. No unknown fields, labels, thresholds or model choices in the packet.

Frame fields

FieldWhen requiredContract
source_indexAlwaysNonnegative source frame integer, strictly increasing. Preserve gaps; do not renumber selected frames.
timestamp_usAlwaysNonnegative integer exposure timestamp in microseconds, strictly increasing on one capture clock. Unix time is not required.
statusAlwaysok / face_unavailable / dropped
source_sizestatus = ok[width, height] of the upright source image, integer dimensions ≥ 2. The source coordinate space stays fixed throughout the capture.
eyes_xystatus = ok[[x0,y0],[x1,y1]] in the upright source image, not the 320 × 320 crop. Finite, in bounds, sorted by x; x1 − x0 ≥ 2 pixels. Required for COLOR too.
imagestatus = okExactly three keys: part, encoding, sha256. The referenced file part must exist and may be used only once.
phaseCOLOR only; requiredwhiteredgreenblue
display_time_usCOLOR only; optionalNonnegative phase display timestamp, no later than current exposure and strictly after the preceding phase exposure. Use the same clock as timestamp_us.
Sequence collection, failed frames and color timing

For rgb_sequence, upload ordered crops and preserve face_unavailable / dropped events without image data. A missing source index, unavailable face, geometry discontinuity or CNN score jump can reset the final window. Twenty frames is the window maximum, not a guarantee of a complete final window. A failed terminal frame returns retry.

An event within frames[]
{"source_index": 7, "timestamp_us": 233331, "status": "face_unavailable"}

For color, send the white/red/green/blue original crops in that order. If interrupted, send the collected prefix with complete=false; do not relabel phases or fabricate missing images. Timestamp checks catch contradictions but do not prove display/exposure synchronization or challenge authenticity. Validate capture timing on the actual device.

04

Build and send a real request

The examples below assume you already have validated crop PNGs and matching acquisition metadata. Coordinates and times shown are illustrative: replace them with measurements from those exact source frames. The helper computes hashes from your files; it does not preprocess images.

  1. Save the helper as send_capture.py; install httpx. Save one of the input JSON examples next to its crop files.
  2. Set API_BASE, API_TOKEN and a new IDEMPOTENCY_KEY for this capture. Keep the key and package unchanged for network retries.
  3. Run the Python command to upload, or use --prepare-only followed by the cURL example. Choose one path per verification.
Complete multipart helper · send_capture.py · no liveness_core dependency

Use trusted local input files. The helper reads image.part relative to the input JSON, fills sha256, writes metadata.json, and sends the same binary bytes. --prepare-only writes metadata.json without making any API call.

send_capture.py
import argparse
import hashlib
import json
import os
from pathlib import Path

import httpx

parser = argparse.ArgumentParser()
parser.add_argument("input", type=Path)
parser.add_argument("--prepare-only", action="store_true")
args = parser.parse_args()
metadata = json.loads(args.input.read_text(encoding="utf-8"))
captures = metadata["captures"] if "captures" in metadata else [metadata]
module_for = {"rgb_single": "rgb_liveness",
              "rgb_sequence": "rgb_liveness", "color": "color_liveness"}
modules = {module_for[capture["mode"]] for capture in captures}
if len(modules) != 1:
    raise ValueError("Use exactly one module per HTTP request")
module = modules.pop()
parts = {}
for capture in captures:
    for frame in capture["frames"]:
        if "image" not in frame:
            continue
        descriptor = frame["image"]
        name = descriptor["part"]
        if name in parts:
            raise ValueError("Each image.part must be unique")
        data = (args.input.parent / name).read_bytes()
        descriptor["sha256"] = hashlib.sha256(data).hexdigest()
        parts[name] = data
text = json.dumps(metadata, ensure_ascii=False, allow_nan=False)
Path("metadata.json").write_text(text, encoding="utf-8")
if args.prepare_only:
    raise SystemExit(0)

base = os.environ["API_BASE"].rstrip("/")
headers = {
    "Authorization": f"Bearer {os.environ['API_TOKEN']}",
    "Idempotency-Key": os.environ["IDEMPOTENCY_KEY"],
}
files = [("metadata", (None, text))]
files.extend((name, (name, data, "application/octet-stream"))
             for name, data in parts.items())
response = httpx.post(
    f"{base}/v1/modules/{module}/verify",
    headers=headers, files=files, timeout=60,
)
print(response.status_code)
print(json.dumps(response.json(), indent=2, ensure_ascii=False))
response.raise_for_status()
for result in response.json()["results"]:
    if result["status"] == "ok":
        print(result["capture_id"], "alive:", result["alive"])
    else:
        print(result["capture_id"], "retry:", result["reason"])

After preparing a cURL upload, do not change the PNG files: their bytes must still match metadata.json. Never set Content-Type manually. Even a capture containing only failure events still needs multipart, as generated by this helper.

A. Silent RGB — a complete single-frame request

Put rgb.png next to input-rgb.json. The sha256 placeholder is replaced by send_capture.py before upload.

input-rgb.json
{
  "protocol_version": 1,
  "capture_id": "rgb-capture-001",
  "mode": "rgb_single",
  "capture_profile": "rgb_single_v1",
  "preprocess_profile": "eye_crop_320_v1",
  "complete": true,
  "frames": [{
    "source_index": 0, "timestamp_us": 100000, "status": "ok",
    "source_size": [640, 480],
    "eyes_xy": [[220.0, 180.0], [340.0, 180.0]],
    "image": {"part": "rgb.png", "encoding": "png",
              "sha256": "REPLACED_BY_SEND_CAPTURE_PY"}
  }]
}
export IDEMPOTENCY_KEY="$(python -c 'import uuid; print(uuid.uuid4())')"
python send_capture.py input-rgb.json --prepare-only
curl --fail-with-body "$API_BASE/v1/modules/rgb_liveness/verify" \
  -H "Authorization: Bearer $API_TOKEN" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
  -F 'metadata=<metadata.json' \
  -F 'rgb.png=@rgb.png;type=image/png'

These commands start a new verification. For a network retry, reuse the existing IDEMPOTENCY_KEY; do not rerun the export line. To use a sequence, set mode=rgb_sequence and capture_profile=rgb_contiguous_v1, then append ordered frame records and failure events.

B. Color — one complete four-phase capture

Prepare white.png, red.png, green.png and blue.png from the corresponding illumination phases. Upload the four crops together; the whole capture costs one unit.

Complete COLOR metadata · input-color.json

Illustrative source coordinates and timing only. Use actual per-frame measurements, including display_time_us if available.

input-color.json
{
  "protocol_version": 1,
  "capture_id": "color-capture-001",
  "mode": "color",
  "capture_profile": "color_wrgb_v1",
  "preprocess_profile": "eye_crop_320_v1",
  "complete": true,
  "frames": [
    {
      "source_index": 0, "timestamp_us": 200000, "status": "ok",
      "phase": "white", "display_time_us": 0,
      "source_size": [640, 480],
      "eyes_xy": [[220.0, 180.0], [340.0, 180.0]],
      "image": {"part": "white.png", "encoding": "png",
                "sha256": "REPLACED_BY_SEND_CAPTURE_PY"}
    },
    {
      "source_index": 1, "timestamp_us": 450000, "status": "ok",
      "phase": "red", "display_time_us": 250000,
      "source_size": [640, 480],
      "eyes_xy": [[221.0, 180.0], [341.0, 180.0]],
      "image": {"part": "red.png", "encoding": "png",
                "sha256": "REPLACED_BY_SEND_CAPTURE_PY"}
    },
    {
      "source_index": 2, "timestamp_us": 700000, "status": "ok",
      "phase": "green", "display_time_us": 500000,
      "source_size": [640, 480],
      "eyes_xy": [[220.0, 181.0], [340.0, 181.0]],
      "image": {"part": "green.png", "encoding": "png",
                "sha256": "REPLACED_BY_SEND_CAPTURE_PY"}
    },
    {
      "source_index": 3, "timestamp_us": 950000, "status": "ok",
      "phase": "blue", "display_time_us": 750000,
      "source_size": [640, 480],
      "eyes_xy": [[220.0, 180.0], [340.0, 180.0]],
      "image": {"part": "blue.png", "encoding": "png",
                "sha256": "REPLACED_BY_SEND_CAPTURE_PY"}
    }
  ]
}
export IDEMPOTENCY_KEY="$(python -c 'import uuid; print(uuid.uuid4())')"
python send_capture.py input-color.json --prepare-only
curl --fail-with-body "$API_BASE/v1/modules/color_liveness/verify" \
  -H "Authorization: Bearer $API_TOKEN" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
  -F 'metadata=<metadata.json' \
  -F 'white.png=@white.png;type=image/png' \
  -F 'red.png=@red.png;type=image/png' \
  -F 'green.png=@green.png;type=image/png' \
  -F 'blue.png=@blue.png;type=image/png'
05

Multiple captures, one request

Wrap complete CapturePacket objects in a captures array and send all referenced image parts together. The batch must target one module. RGB single frames and RGB sequences can share a request; RGB and COLOR cannot.

Create input-batch.json from two prepared captures
import json
from pathlib import Path

captures = []
for folder in ("capture-a", "capture-b"):
    capture = json.loads(Path(folder, "input.json").read_text())
    for frame in capture["frames"]:
        if "image" in frame:
            name = frame["image"]["part"]
            frame["image"]["part"] = f"{folder}/{name}"
    captures.append(capture)
Path("input-batch.json").write_text(json.dumps({"captures": captures}))

Give the two input captures distinct capture_id values. Run the same send_capture.py helper on input-batch.json with a new IDEMPOTENCY_KEY. Each prefixed part name maps to its matching form field; filenames alone do not identify a part.

  • The only top-level key is captures. Every capture_id and image.part must be unique within the request. No extra or unreferenced file parts.
  • All inputs are validated before inference. Quota is reserved for the whole batch atomically: N captures cost N units, or the whole batch is rejected.
  • results preserves input order; RGB temporal windows stay independent. Internal CNN batching is a server setting and does not change this HTTP contract or charging.
06

Read the decision, not just HTTP 200

ResultMeaningClient action
status: "ok"
alive: true
Model score is strictly greater than threshold.Liveness passed under the returned policy.
status: "ok"
alive: false
Model classified the capture as non-live.Handle as a failed liveness check.
status: "retry"No decision: alive and score are null. The capture was incomplete or had insufficient usable terminal data.Read reason and recollect. Use a new capture_id and idempotency key; this is a new charged verification.
Illustrative HTTP 200 response · single RGB capture

Scores and quota values below are examples, not a recorded model result. usage is an array of module quotas.

application/json
{
  "request_id": "res_example",
  "module_id": "rgb_liveness",
  "charged_units": 1,
  "results": [{
    "capture_id": "rgb-capture-001", "mode": "rgb_single",
    "status": "ok", "alive": true, "score": 0.91, "reason": null,
    "final_window_frames": 0, "window_frame_indices": [],
    "reset_events": [], "single_score": 0.91,
    "temporal_logit": null, "color_logits": null,
    "model_set": "raw_v1", "decision_policy": "single_frame_v1",
    "threshold": 0.5, "min_final_frames": null
  }],
  "usage": [{
    "module_id": "rgb_liveness", "limit": 1000, "period": "day",
    "used": 1, "remaining": 999, "reset_at": "2027-01-02T00:00:00Z"
  }]
}
FieldsHow to use them
request_idTrace identifier, also returned as X-Request-ID. Keep it for support and troubleshooting.
charged_units / usageUnits attributed to the operation and quota snapshot. An idempotent replay returns the original snapshot; query /v1/me for current usage.
model_set / decision_policyModel and decision policy identifiers for traceability. threshold is chosen by the service, not passed by the client.
final_window_frames
window_frame_indices
reset_events
RGB sequence diagnostics: final window length, included source indices and reset reasons. min_final_frames applies only to sequences; it is null for other modes.
single_score
temporal_logit
color_logits
Mode-specific diagnostic scores. Use status + alive for the decision; do not replace them with a diagnostic branch output.
07

Tokens and predictable usage

Send Authorization: Bearer YOUR_BUSINESS_TOKEN on every /v1 request. The operator controls your token’s enabled state, expiry and per-module grants. Account login sessions cannot be used as business API tokens.

One capture = one unit.

An RGB recording is one unit; four COLOR phases together are one unit; N captures cost N units. Live, non-live and normal retry results are all charged. Authentication, validation, capacity and quota rejections are not charged.

Quota settingRefresh / behavior
period: "day"Resets at 00:00 UTC each day, independent of the device’s timezone.
period: "month"Resets at 00:00 UTC on the first day of each calendar month.
period: "never"Cumulative quota; no automatic refresh. reset_at is null.
limit: nullUnlimited calls, with usage still recorded. remaining is null.
limit: 0Authorized module, but no available units.

Use GET /v1/me to read current usage; these read-only calls do not consume algorithm quota. Changing a limit or disabling and re-enabling a grant does not reset usage within the same period. Send tokens over HTTPS in production; keep administrator credentials out of client apps.

08

Retry safely, without double charging

Create one Idempotency-Key per verification and retain it with the unchanged package until the operation is resolved. The key is scoped to your token and accepts 1–200 printable ASCII characters without spaces. UUIDs are a convenient choice.

  • Timeout or disconnection: resend with the same key, module, metadata and file bytes. A received inference may continue after the connection closes.
  • Completed replay: returns the saved response with Idempotency-Replayed: true, without inference or another charge. JSON whitespace and key order may differ; the logical payload must match.
  • New images or recollection: use a new key and capture_id. Replaying an old retry result does not create a new verification. Without a key, each HTTP call is independent and may be charged again.
Error response shape
{
  "error": {"code": "quota_exceeded", "message": "Quota exceeded for module: rgb_liveness"},
  "request_id": "example-request-id"
}
HTTP / codeWhat to do
400
invalid_idempotency_key
invalid_content_length
Fix the invalid header. Do not loop on the same malformed request.
401 invalid_tokenCheck the Bearer token. It may be missing, invalid, expired, disabled, revoked or the wrong token type. Contact the operator.
403 module_forbidden
404 module_not_found
Request authorization for the module, or correct the module ID.
409 request_in_progressThe same key is still pending. Wait for Retry-After and retry the unchanged request with that key.
409 idempotency_conflictThe key was used with different input. Restore the original package, or use a new key only if this is a new verification.
413 payload_too_largeReduce request size or batch size. Respect both the service and reverse proxy limits.
415 unsupported_media_type
422 invalid_input
Use multipart, then fix metadata, frame order, encoding, dimensions or hashes as indicated. Invalid image encoding is a 422 input error.
429 quota_exceededWait until reset or ask the operator for more quota. Day/month quotas include Retry-After; never quotas do not auto-reset.
503 server_busy
module_unavailable
Retry the same package and key after Retry-After, with bounded exponential backoff and jitter. module_disabled requires the operator to enable the module.
500 inference_failedReserved quota was refunded. Retry with the same key after a delay; report repeated failures with request_id.
500 settlement_failedSettlement needs operator recovery; a reservation may remain pending. Keep the original key and contact support rather than starting duplicate operations.
500 internal_errorKeep the package, key and request_id. A safe retry uses the same key; persistent failures need operator investigation.
09

Know the boundaries

These values describe this deployment. The body limit includes multipart overhead; a reverse proxy may enforce a smaller limit. Split large batches before uploading.

32 MiBMaximum HTTP body
1 MiBPer image / metadata field
32Captures per request
256Total events per request
128Events per capture
2Concurrent calls per API worker
Deployment settingCurrent value
Enabled modulesrgb_liveness, color_liveness
CNN execution / maximum internal batchsequential / 4
Minimum final RGB sequence window1

Single RGB accepts at most one event; COLOR at most four. The RGB window is at most 20 frames. A minimum window of 1 preserves the original numerical baseline; it is not a calibrated production capture policy. Coordinate stricter capture requirements with the operator. Single-frame mode is unaffected by the sequence minimum.

There is no unbounded inference queue. Capacity is reserved through upload, preparation and execution; excess requests return 503. The database stores token hashes, quota records, small result JSON and request audit metadata (including IP and approximate country). For designated demo tokens, complete uploaded images, metadata and verification results are additionally archived by UTC day. Other tokens do not archive images by default. Confirm the archive and retention configuration with your operator.

10

Service administration

Operators sign in with an administrator username and password to manage users and their business tokens, module grants, expiry and quotas. User login is currently disabled. Administration requires a session cookie and CSRF protection; business tokens cannot access it. A new business token is shown in full only once.

EndpointPurpose
GET /health/live
GET /health/ready
Unauthenticated process and model-readiness checks. ready returns 503 when unavailable.
/adminOpen the administration console ↗
/admin/api/users
/admin/api/modules
/admin/api/tokens
Administrator-only user, module and token management with a login session. Refer to the interactive reference for methods and schemas.
11

Android integration guide

Android runs local InspireFace and owns camera capture, face checks, crop generation, networking and UI state. Reuse the original demo recording interaction, prepare one complete capture, then send it once for cloud RAW liveness inference.

The handoff is a prepared CapturePacket.

No camera callbacks or SDK face tokens cross the API boundary. Android submits original 320 × 320 crops and source-frame metadata; the server computes normalization, color differences and model scores.

1. Collect a complete recording

ItemSilent RGBColor liveness
Modulergb_livenesscolor_liveness
modergb_sequencecolor
capture_profilergb_contiguous_v1color_wrgb_v1
Ready to submit20 consecutive valid frames4 valid frames: white → red → green → blue
Cost of one recording1 unit for all 20 images1 unit for all 4 images
  • RGB: retain real source indices, increasing by 1; any nonnegative starting index is valid. Collect one continuous valid segment. If local face/continuity/geometry checks fail, restart that segment rather than joining separated selections.
  • Use eye_crop_320_v1 for both modes. Crops are upright and unmirrored. eyes_xy must be the current frame’s two original-image eye coordinates sorted by x; source_size is the upright original width and height, not 320 × 320.
  • Use one monotonic time base. Convert nanoseconds to integer microseconds with ns / 1000L. timestamp_us must strictly increase; never mix wall-clock time with camera/display elapsed time.
  • COLOR: source indices only need to increase. phase must match the actual WRGB stage. display_time_us is optional; if present it must follow the previous exposure and be no later than the current exposure. RGB frames omit both phase and display_time_us.

Twenty frames is this Android integration’s submission target. The general core also supports variable-length input. CNN score changes can still shorten the final GRU window; read final_window_frames from the response. Android does not need a local copy of the cloud liveness model to predict this.

Match the current RGB geometry rule: let d_old and d_new be consecutive original-image eye distances and m_old / m_new their eye midpoints. Keep 0.65 <= d_new / d_old <= 1.5 and distance(m_new, m_old) / d_old <= 0.6. Keep source_size fixed for the recording; restart the segment when these conditions fail. Eye coordinates must be finite, inside the source image and ordered by x with at least two pixels of horizontal separation.

For eye_crop_320_v1, reuse ModelMath.crop() from the original arcface/android/flash_lab demo (ModelMath.java), with ModelMathTest.java as the frozen comparison starting point. Feed it the local InspireFace eye coordinates for that same upright frame and validate pixel equality. The wire example below begins after that exact crop is ready.

2. Encode RGB8 and describe each frame

Use rgb8 for the first integration: 307200 bytes per crop, row-major RGB, three bytes per pixel. Extract channels explicitly from the agreed crop’s ARGB int array. This avoids PNG alpha and byte-order differences; do not convert via RGB565.

AndroidRgb8.java
public final class AndroidRgb8 {
    private AndroidRgb8() {}

    // Input: the agreed 320 x 320 crop as ARGB int pixels.
    public static byte[] fromArgb(int[] cropArgb) {
        if (cropArgb.length != 320 * 320) {
            throw new IllegalArgumentException("Expected a 320 x 320 crop");
        }
        byte[] rgb = new byte[307200];
        int offset = 0;
        for (int pixel : cropArgb) {
            rgb[offset++] = (byte) (pixel >>> 16); // R
            rgb[offset++] = (byte) (pixel >>> 8);  // G
            rgb[offset++] = (byte) pixel;         // B
        }
        return rgb;
    }
}

Add one metadata record per frame. The example below is one RGB record, not a complete 20-frame request. Replace coordinates, dimensions, index and time with actual local observations. The Java request builder calculates image.sha256; the placeholder is not sent as a digest.

One entry in frames[]
{
  "source_index": 340,
  "timestamp_us": 1200000,
  "status": "ok",
  "source_size": [
    640,
    480
  ],
  "eyes_xy": [
    [
      180.5,
      160.25
    ],
    [
      300.5,
      161.0
    ]
  ],
  "image": {
    "part": "f0340.rgb",
    "encoding": "rgb8",
    "sha256": "CALCULATED_BY_THE_JAVA_BUILDER_FROM_UPLOADED_BYTES"
  }
}

For COLOR, add phase to each record, plus display_time_us when available. Keep source_size and eyes_xy for all four frames. Only protocol fields belong in metadata; save SDK objects and device diagnostics separately on Android.

3. Build one repeatable multipart request

The following Java example uses Android org.json and OkHttp. Reuse the HTTP client already selected by the Android project. preparedFrames is the ordered array of the records above; rgb8Parts maps each image.part to the exact RGB8 byte array. Perform crop conversion, hashing and request construction on a worker thread.

Java / OkHttp request builder — expand to copy
AndroidCaptureRequest.java
import java.security.MessageDigest;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.json.JSONArray;
import org.json.JSONObject;
import okhttp3.MediaType;
import okhttp3.MultipartBody;
import okhttp3.Request;
import okhttp3.RequestBody;

public final class AndroidCaptureRequest {
    private AndroidCaptureRequest() {}

    // Build ONCE after a complete recording. Keep this Request for retries.
    // preparedFrames contain the fields shown below; rgb8Parts is keyed by image.part.
    public static Request buildRgb8(
            String apiBase, String businessToken, String mode,
            JSONArray preparedFrames, Map<String, byte[]> rgb8Parts) throws Exception {
        boolean color = "color".equals(mode);
        if (!color && !"rgb_sequence".equals(mode)) {
            throw new IllegalArgumentException("Use rgb_sequence or color");
        }
        int count = color ? 4 : 20;
        if (preparedFrames.length() != count) {
            throw new IllegalArgumentException("Capture is not complete");
        }
        String module = color ? "color_liveness" : "rgb_liveness";
        String profile = color ? "color_wrgb_v1" : "rgb_contiguous_v1";
        String[] phases = {"white", "red", "green", "blue"};
        String captureId = UUID.randomUUID().toString();
        JSONArray frames = new JSONArray(preparedFrames.toString());
        MultipartBody.Builder multipart = new MultipartBody.Builder()
                .setType(MultipartBody.FORM);
        Set<String> used = new HashSet<>();
        long lastIndex = -1, lastTimeUs = -1;

        for (int i = 0; i < count; i++) {
            JSONObject row = frames.getJSONObject(i);
            long index = row.getLong("source_index");
            long timeUs = row.getLong("timestamp_us");
            if (!"ok".equals(row.getString("status")) || index <= lastIndex
                    || timeUs <= lastTimeUs || (!color && i > 0 && index != lastIndex + 1)) {
                throw new IllegalArgumentException("Invalid frame status, order or continuity");
            }
            if (color) {
                if (!phases[i].equals(row.getString("phase"))) {
                    throw new IllegalArgumentException("Expected white/red/green/blue");
                }
                if (row.has("display_time_us")) {
                    long displayed = row.getLong("display_time_us");
                    if (displayed < 0 || displayed > timeUs || (i > 0 && displayed <= lastTimeUs)) {
                        throw new IllegalArgumentException("Invalid phase timestamps");
                    }
                }
            } else if (row.has("phase") || row.has("display_time_us")) {
                throw new IllegalArgumentException("RGB must omit color-phase fields");
            }
            lastIndex = index;
            lastTimeUs = timeUs;

            JSONObject image = row.getJSONObject("image");
            String part = image.getString("part");
            byte[] source = rgb8Parts.get(part);
            if (!used.add(part) || source == null || source.length != 307200) {
                throw new IllegalArgumentException("Missing, duplicate or invalid RGB8 part");
            }
            byte[] snapshot = source.clone(); // Keep retries independent of capture buffers.
            image.put("encoding", "rgb8");
            image.put("sha256", sha256(snapshot));
            multipart.addFormDataPart(part, part,
                    RequestBody.create(MediaType.parse("application/octet-stream"), snapshot));
        }
        if (used.size() != rgb8Parts.size()) {
            throw new IllegalArgumentException("Unreferenced image parts");
        }
        JSONObject packet = new JSONObject()
                .put("protocol_version", 1)
                .put("capture_id", captureId)
                .put("mode", mode)
                .put("capture_profile", profile)
                .put("preprocess_profile", "eye_crop_320_v1")
                .put("complete", true)
                .put("frames", frames);
        multipart.addFormDataPart("metadata", packet.toString());
        String base = apiBase.endsWith("/") ? apiBase.substring(0, apiBase.length() - 1) : apiBase;
        return new Request.Builder()
                .url(base + "/v1/modules/" + module + "/verify")
                .header("Authorization", "Bearer " + businessToken)
                .header("Idempotency-Key", captureId)
                .post(multipart.build())
                .build();
    }

    private static String sha256(byte[] data) throws Exception {
        byte[] digest = MessageDigest.getInstance("SHA-256").digest(data);
        char[] hex = "0123456789abcdef".toCharArray();
        StringBuilder out = new StringBuilder(64);
        for (byte b : digest) {
            out.append(hex[(b & 0xff) >>> 4]);
            out.append(hex[b & 0x0f]);
        }
        return out.toString();
    }
}
// Upload asynchronously with your existing OkHttpClient and Callback:
// Request frozen = AndroidCaptureRequest.buildRgb8(API_BASE, businessToken, mode, frames, parts);
// client.newCall(frozen).enqueue(callback);
// Retry: create a NEW Call using the SAME frozen Request.
// New recording: call buildRgb8 again to create a new capture_id / Idempotency-Key.
Keep the prepared request until the operation is resolved.

Call the builder once per new recording. For a timeout or disconnect, create a new OkHttp Call from the same Request; retain the key, metadata and image bytes. Rebuilding the packet creates a new key and may charge again. Let MultipartBody generate Content-Type and its boundary; metadata uses the two-argument addFormDataPart overload and has no filename.

4. Complete the Android result flow

ResponseAndroid action
HTTP 200 · status=okMatch capture_id to the active operation, then use alive and score. HTTP 200 alone is not a live verdict.
HTTP 200 · status=retryRecollect with a new capture_id and key; score/alive are null. A valid retry result is still one charged verification.
401 / 403 / 429Handle credentials, module permission or quota; do not report a spoof verdict. Quota reset is UTC midnight for day grants.
409 / 503 / network timeoutFollow Retry-After and the errors section. Keep the original key and bytes for a retry of the same verification.

Recommended local states: Ready → Preparing → Capturing → Encoding → Uploading → Result / Retry / Error. Keep late callbacks associated with their original capture. Check GET /v1/me and GET /v1/modules when configuring the business token; these queries do not consume verification quota.

5. Android handoff checklist

  • First send a frozen RGB8 capture to check fields, channel order, hashes and server results; then connect local InspireFace and camera collection.
  • Verify RGB20 and COLOR4 separately, including interruption/recollection and retrying the same request without duplicate charges.
  • Enable INTERNET in the Android manifest. Configure HTTP access only for the development endpoint; use the deployment HTTPS endpoint for production. Keep the business token in app configuration, redact it from logs and exports, and never put administrator account credentials in the app.
  • Collect exposure/phase timing and preprocessing performance on the target device. Local quality checks, lifecycle handling and retry UI belong to the Android implementation.