
# Biometry Integration Architect — API v2

This document is an instruction set for AI coding agents (Claude, GPT, Copilot,
etc.) building biometric verification against **Biometry API v2**. Feed it to
your agent as context before it writes integration code.

Machine-readable contract, if your agent can fetch a URL:
`https://api.biometrysolutions.com/api-gateway/v2/openapi.json`.

Working on an existing v1 integration? See the
[v1 skill](https://developer.biometrysolutions.com/concepts/ai-integration-skill/) and the
[migration guide](https://developer.biometrysolutions.com/api-v1/).

---

## System architecture

Biometry follows an **API proxy pattern**. The customer's backend proxies all
Biometry calls; the frontend never calls Biometry directly, because the API
token is a secret that must not reach a browser or mobile binary.

```
┌─────────────────────┐     ┌──────────────────────┐     ┌─────────────────────┐
│  Customer Frontend  │     │   Customer Backend   │     │    Biometry API     │
│                     │     │                      │     │                     │
│  Web SDK / Flutter  │────▶│  /api/verify         │────▶│  /api-gateway/v2/   │
│  (captures media)   │◀────│  (holds API token)   │◀────│                     │
└─────────────────────┘     └──────────────────────┘     └─────────────────────┘
```

Base URL: `https://api.biometrysolutions.com/api-gateway/v2`

Other prefixes you will need (unversioned, shared with v1):

- `/api-consent` — consent records
- `/api-transactions` — transaction history
- `/api-projects` — API key management
- `/api-auth` — Console user auth; **not** part of a backend integration

---

## Authentication

One header, on every call:

```
Authorization: Bearer <API_TOKEN>
```

The token is a JWT minted in the Console. It encodes `project_id`, the enabled
services, and optionally a scoring system ID. It **does not expire** — it is
valid until revoked, so treat it as a long-lived secret: environment variable or
secret manager, one token per application, never in version control.

A second credential exists — the **Console user session** — and it is not
interchangeable. These endpoints require it and will reject an API token with
`401`: scoring systems (`/v2/scoring-systems/*`), webhooks
(`/v2/webhooks/*`), consent document authoring, `GET /v2/sessions`,
`DELETE /v2/users/{user_id}`. If your task says "create a webhook", the answer
is the Console, not the API token.

---

## The two rules that differ most from v1

**1. Identity is `user_id`, in the body.** No `X-User-Fullname`. `user_id` is an
opaque, caller-chosen string, 1–128 chars matching `^[A-Za-z0-9._:@-]+$`, unique
inside the project. Use the customer's own primary key, a UUID, or a hash —
never a person's name, and never anything with spaces (it will be rejected with
`400`).

**2. Parameters live in the body.** File-carrying endpoints are
`multipart/form-data` with a single JSON part named `request` alongside the file
parts. The v1 business headers (`X-User-Fullname`, `X-Session-ID`,
`X-Action-Trigger`, `X-Use-Prefilled-Video`, `X-Inhouse-Docauth`,
`X-Inhouse-MRZ`) are **stripped** on v2 routes — sending them does nothing.

Headers that remain headers: `Authorization`, `X-Request-Id`, `X-Client-App`,
`X-Client-Version`, `X-App-Version`, `X-Device-Info`, `X-Geo-Location`.

---

## Response envelope

Every response, success or failure:

```json
{
  "data": { },
  "decision": { "status": "pass", "score": 87.5, "reasons": [] },
  "meta": { "request_id": "b6f0…", "message": "video processed successfully" }
}
```

```json
{
  "error": { "code": "invalid_request", "message": "user_id is required" },
  "meta": { "request_id": "b6f0…" }
}
```

- `data` — endpoint payload. Omitted when there is nothing to return.
- `decision` — present only when a scoring system was evaluated.
  `status` ∈ `pass` | `refer` | `fail`.
- `meta.request_id` — always present. **Log it on every call**; it is what
  Biometry support traces.

Service results are keyed by public service name: `face_liveness_detection`,
`active_speaker_detection`, `visual_speech_recognition`, `face_recognition`,
`voice_recognition`. Each value is `{ code, description, result, score }` with
`score` on a `0`–`100` scale. Only services enabled on the token appear.

---

## Failure modes an agent must handle

These are the three things naive generated code gets wrong.

**1. `200` does not mean "passed".** Services run in parallel and report
independently. A failed one appears as `{"error": "…"}` under its own key while
the HTTP status stays `200`:

```json
{ "data": { "face_recognition": { "error": "there is no enrolled face for this person" } } }
```

Always inspect each service entry and the `decision`, never the status alone.

**2. Rejected enrollment is also `200`.** `POST /v2/enrollments/face` returns
`meta.message: "failed to enroll face"` with a non-zero
`data.enrollment_result.code` (`1` no face / too small / bad quality, `2` more
than one face, `3` face not centered). Voice returns `data.status` of `error` or
`qafailed`. Branch on those, not on the status code.

**3. Missing consent silently drops services.** Face Recognition and Voice
Recognition require authorization consent. Without it they are removed from the
call and the response carries the header `X-Removed-Services: true` — you get a
`200` with the identity check simply absent. Check consent up front or check
that header.

Error codes to branch on: `invalid_request` (400), `invalid_api_key` /
`invalid_token` (401), `service_not_enabled` (403), `forbidden` (403, includes
missing consent), `not_found` (404), `conflict` (409),
`unprocessable_entity` (422), `internal_error` (500, retry with backoff).

---

## Consent

Consent still lives on the unversioned consent service, and this is the single
most common integration mistake: **pass your `user_id` as `user_fullname`**.
That field is the identity key the platform matches on.

```
POST /api-consent/consent            → authorization consent (recognition, enrollment)
POST /api-consent/strg-consent       → storage consent (enrollment only)
Authorization: Bearer <API_TOKEN>
Content-Type: application/json

{ "is_consent_given": true, "user_fullname": "<same value as user_id>" }
```

`POST` grants (rejects `false` with `400`); `PUT` on the same path changes an
existing record — that is how a withdrawal is recorded. `GET /api-consent/history?user_fullname=…`
returns the audit trail.

---

## Integration flow: verify a returning user

### 1. Pre-flight — is this user enrolled?

```
GET /api-gateway/v2/users/{user_id}
```

```json
{ "data": { "user_id": "…", "enrollments": { "face": true, "voice": false }, "voice_phrases": [] } }
```

An unknown user is **not** a `404` — it returns `enrollments` all `false`. Use
this to route between onboarding and login, and to skip a check the user cannot
pass. Batch form: `GET /v2/users?ids=a,b,c` (max 100).

### 2. Start a session

```
POST /api-gateway/v2/sessions          → { "data": { "session_id": "…" } }
POST /api-gateway/v2/sessions?warmup=true   (pre-warms ML services)
```

Sessions group the calls of one attempt and store the first uploaded video, so
`/v2/face-match` can reuse it. Pass `session_id` in the `request` part of every
subsequent call.

### 3. Liveness

```
POST /api-gateway/v2/liveness
Content-Type: multipart/form-data

request = {"user_id":"…","session_id":"…","phrase":"one two three four five six"}
video   = <file>
```

Returns `face_liveness_detection`, `active_speaker_detection`,
`visual_speech_recognition`. `phrase` is required — it is what Visual Speech
Recognition compares its transcript against. Optional fields in `request`:
`vocabulary`, `trigger`, `services.exclude` (array of service keys).

### 4. Identity

```
POST /api-gateway/v2/face-verify     request = {"user_id","phrase","session_id"}, video = <file>
POST /api-gateway/v2/voice-verify    request = {"user_id","phrase","session_id"}, voice = <file>
```

Each compares against the user's enrolled template and returns its own
`decision`. Run them only for modalities the pre-flight said are enrolled.

### 5. End the session

```
POST /api-gateway/v2/sessions/{id}/end
{ "phone_number": "+996700000000" }     ← optional SIM-swap check
```

**Ending a session does not return a combined verdict.** Compose the per-call
`decision` objects yourself. Do not generate code that waits for a session-level
score.

---

## Integration flow: document onboarding

```
POST /api-gateway/v2/sessions                                  → session_id
POST /api-gateway/v2/documents/check
     request  = {"session_id":"…","provider":"inhouse"}          (provider/mrz_provider optional)
     document = <file>                                          → extracted fields + portrait_photo
POST /api-gateway/v2/liveness       request+video               → anti-spoof results
POST /api-gateway/v2/face-match
     request         = {"user_id":"…","session_id":"…","use_session_video":true}
     reference_image = <document portrait>                      → match against the stored video
POST /api-gateway/v2/enrollments/face                           (optional, for future logins)
     request = {"user_id":"…","use_session_video":true}          → enrolls from the session's video
POST /api-gateway/v2/sessions/{id}/end
```

`use_session_video` is honoured by `/v2/face-match`, `/v2/face-verify`,
`/v2/voice-verify` and `/v2/enrollments/face` — every one of them reuses the
video the liveness step stored on the session, so the capture is uploaded once.
`/v2/enrollments/voice` does not take it: a voiceprint needs several distinct
recordings. Enroll before `/v2/sessions/{id}/end`; once the session is closed
its video can no longer be enrolled from.

---

## Asynchronous: deepfake detection

```
POST /api-gateway/v2/deepfake/checks     request = {"user_id","session_id"}, video = <file>   → 201
GET  /api-gateway/v2/deepfake/checks/{request_id}
```

The ID to poll with is `data.metadata.request_id` from the `201` response — not
`meta.request_id`, which identifies the HTTP call. Poll until `data.status` is
`completed` or `failed`; the verdict is `data.result.prediction` (`real` /
`fake`) with `confidence` and `score`. Or register a webhook and skip polling.

---

## Checklist before shipping generated code

- [ ] API token read from the environment on the **backend**; never sent to a client
- [ ] `user_id` is opaque and stable, matches `^[A-Za-z0-9._:@-]{1,128}$`, and is the same value used as `user_fullname` for consent
- [ ] Consent collected before enrollment (authorization **and** storage) and before recognition (authorization)
- [ ] Parameters in the `request` JSON part, not in `X-*` headers
- [ ] Every response checked per service, not by HTTP status
- [ ] `meta.request_id` logged on every call
- [ ] `session_id` passed to every call in one attempt, and the session ended on both success and failure paths
- [ ] Retries only on `5xx`, with backoff; never retry a successful enrollment or deepfake submission
- [ ] Media captured client-side via the [Web](https://developer.biometrysolutions.com/sdks/web/) or [Flutter](https://developer.biometrysolutions.com/sdks/flutter/) SDK and forwarded through your backend

---

## Reference

| Topic | Page |
|---|---|
| Envelope, identity model, service keys | [API v2 Overview](https://developer.biometrysolutions.com/concepts/api-calls/) |
| End-to-end curl walkthrough | [v2 Quick Start](https://developer.biometrysolutions.com/quick-start/) |
| Every error code | [Error catalog](https://developer.biometrysolutions.com/api/errors/) |
| Verification endpoints in full | [Verification](https://developer.biometrysolutions.com/api/verification/) |
| How the verdict is computed | [Scoring Systems](https://developer.biometrysolutions.com/concepts/scoring-systems/) |
| Reading results back | [Transactions](https://developer.biometrysolutions.com/api/transactions/) |
