# OneSign eKYC — Tài liệu nhà phát triển

Nền tảng eKYC **Stateless, on-device** (Apple Silicon/macOS): OCR giấy tờ tùy thân,
so khớp khuôn mặt, kiểm tra thực thể sống (liveness), xác minh tổng hợp, OCR tài liệu
tự do, trợ lý chat AI on-device. Máy chủ **không lưu ảnh/PII**; kết quả được **ký
HMAC-SHA256** chống can thiệp. SDK v1.1.0.

- Base URL ví dụ: `https://ekyc.hqv.biz`
- Xác thực: header `X-API-KEY: <tenant key>` cho mọi endpoint `/ai/v1/*`
- Envelope phản hồi: `{ "statusCode": 200, "message": "IDG-00000000", "object": { ... } }`
  (message `IDG-00000000` = OK)
- Tự đăng ký tenant & quản lý key: `/portal` (nếu quản trị viên bật). Điều khoản: `/terms`

---

## 1. Bắt đầu nhanh (SDK JavaScript)

Nhúng SDK và gọi `HQVeKYC.open()`. Hàm trả về một **Promise** chứa kết quả; cũng có
callback `onComplete`.

```html
<script src="https://ekyc.hqv.biz/sdk/hqv-ekyc.js"></script>
<script>
  const kq = await HQVeKYC.open({
    apiBase: "https://ekyc.hqv.biz",
    apiKey:  "hqv_live_xxxxxxxx",   // khóa tenant
    lang: "vi",                     // "vi" | "en"
    appName: { vi: "Client Portal", en: "Client Portal" }
  });

  if (kq.status === "success" && kq.decision === "PASS") {
    // kq.document, kq.face, kq.liveness, kq.files, kq.attestation ...
  }
</script>
```

Yêu cầu **HTTPS** (để dùng camera) và domain phải nằm trong `allowed_origins` của tenant (xem §9).

## 2. Tùy chọn `HQVeKYC.open(options)`

| Option | Kiểu · Mặc định | Mô tả |
|---|---|---|
| `apiBase` | string · `location.origin` | URL máy chủ eKYC |
| `apiKey` | string · `""` | Khóa tenant. Bắt buộc (trừ khi dùng `requestHook`) |
| `lang` | `"vi"` \| `"en"` · `"vi"` | Ngôn ngữ khởi tạo; người dùng đổi được trong modal |
| `requireConsent` | bool · `true` | Bắt buộc đồng ý điều khoản trước khi xử lý |
| `termsUrl` | string · `apiBase+"/terms"` | Link điều khoản eKYC |
| `appName` | `{vi,en}` · `null` | Tên ứng dụng của bạn (hiển thị) |
| `appTerms` | object · `null` | Điều khoản riêng: `{label:{vi,en}, url:{vi,en}}` → thêm 1 checkbox đồng ý |
| `captureBack` | bool · `true` | Có chụp mặt sau giấy tờ không |
| `requireLiveness` | bool · `true` | Bắt buộc selfie + liveness + so khớp mặt |
| `checkExpiry` | bool · `true` | Giấy tờ hết hạn → FAIL |
| `allowUpload` | bool · `true` | Cho phép TẢI ảnh giấy tờ từ thiết bị. Selfie luôn bắt buộc camera live |
| `maxRetries` | int · `3` | Số lần làm lại mỗi bước khi fail |
| `autoCapture` | bool · `true` | Tự chụp selfie khi bắt nét khuôn mặt (1 mặt, giữa khung, đủ nét, ổn định ~1s). Dùng FaceDetector API (Chrome/Android) hoặc bộ phát hiện pico nhúng sẵn (Safari/iOS); không có thì chụp tay |
| `returnFiles` | bool · `true` | Trả kèm `files[]` (ảnh + report + JSON) |
| `maxEdge` / `jpegQuality` | `1600` · `0.9` | Chuẩn resize ảnh trước khi gửi |
| `theme` | object · `null` | Màu thương hiệu: `{primary, secondary, success, danger, bg, card, fg, line, muted, onPrimary}` |
| `requestHook` | `fn(path, formData)` · `null` | Tự vận chuyển request (vd proxy qua backend để giấu key). Trả về JSON envelope |
| `onComplete` / `onCancel` | fn · `null` | Callback khi xong / khi hủy |

### 2b. Tùy biến màu thương hiệu

```js
await HQVeKYC.open({
  apiBase: "https://ekyc.hqv.biz", apiKey: "hqv_live_xxxxxxxx",
  theme: {
    primary:   "#00a867",   // nút chính, thanh bước, link
    secondary: "#004f30",   // thanh đang xử lý
    success:   "#348D00",   // PASS, còn hạn, khớp
    danger:    "#ED542C",   // FAIL, hết hạn, lỗi
    bg:        "#1a5093"     // nền modal
    // (tùy chọn) card, fg, line, muted, onPrimary
  }
});
```

## 3. Luồng xử lý

Đồng ý điều khoản → chụp mặt trước → mặt sau → bóc tách + kiểm tra hiệu lực → chụp
khuôn mặt (liveness) → kết quả. Fail ở bước nào → yêu cầu làm lại đúng bước đó (tối đa `maxRetries`).

## 4. Đối tượng kết quả (Result)

```jsonc
{
  "status": "success" | "cancelled" | "declined",
  "decision": "PASS" | "FAIL",
  "reasons": ["face_mismatch" | "liveness_failed" | "document_expired" | "ocr_unreadable"],
  "consent": { "agreed": true, "at": "ISO-8601", "version": "1.0", "lang": "vi",
               "ekyc_terms": true, "app_terms": true },
  "document": { "doc_type": "CCCD|CC|PASSPORT|UNSUPPORTED",
                "id","name","dob","sex","nationality","home","address","doe" },
  "validity": { "doe": "dd/mm/yyyy", "expired": false, "days_to_expiry": 495 },
  "raw_text": { "front": ["...dòng OCR..."], "back": ["..."] },
  "face": { "match": true, "prob": 77.4, "similarity": 0.548,
            "threshold": 0.35, "quality_ok": true },
  "liveness": { "liveness": "success", "is_live": true, "score": 0.99, "threshold": 0.55 },
  "image_hashes": { "id_card": "sha256hex", "selfie": "sha256hex" },
  "attestation": { "payload": { ... }, "signed": {
      "alg": "HMAC-SHA256", "key_id": "hqv-ekyc-k1",
      "signed_at": "ISO-8601", "signature": "hex" } },
  "params": { "faceThreshold":0.35, "livenessThreshold":0.55, "maxRetries":3, "lang":"vi" },
  "retries": { "doc": 0, "selfie": 1 },
  "files": [ /* xem §5 */ ],
  "images": { "front":"data:...", "back":"data:...", "selfie":"data:..." },
  "timestamp": "ISO-8601", "sdk": "1.1.0"
}
```

`tenant_id` nằm trong `attestation.payload`. Trường `doc_type`: `CCCD` (CCCD gắn chip),
`CC` (thẻ Căn cước mẫu 2024), `PASSPORT` (hộ chiếu). Giấy tờ KHÁC (GPLX, CMND cũ...) →
`UNSUPPORTED` (không hỗ trợ).

## 5. `files[]` — để lưu vào hệ thống của bạn

Mỗi phần tử: `{ kind, name, mime, size, dataUrl, data? }`.

| kind | mime | Nội dung |
|---|---|---|
| `id_front` / `id_back` | image/jpeg | Ảnh giấy tờ đã chụp/tải |
| `selfie` | image/jpeg | Ảnh khuôn mặt |
| `report` | image/png | 1 ảnh tổng hợp (thông tin + tỷ lệ khớp + kết luận) |
| `analysis` | application/json | Toàn bộ dữ liệu phân tích + chữ ký (cũng có ở `.data`) |

## 6. Xác minh chữ ký (chống can thiệp)

**Nên xác minh ở BACKEND của bạn**, không tin dữ liệu từ trình duyệt. Chữ ký do máy chủ
eKYC tạo (HMAC-SHA256) bằng `signing_key` chia sẻ. Hai bước: (a) so `image_hashes` với
sha256 của ảnh nhận được; (b) verify HMAC.

> ⚠️ **`signing_key` ≠ API key.** Khóa verify HMAC là `signing_key` (khóa xác minh dùng chung,
> lấy từ quản trị viên: `GET /admin/api/verify-key`). API key (`hqv_sk_`/`hqv_pk_`) CHỈ để
> xác thực ở header `X-API-KEY` — **KHÔNG** dùng làm khóa HMAC (lỗi thường gặp nhất). Cấp lại
> (rotate) API key KHÔNG làm đổi `signing_key`.

**Canonical = RFC 8785 (JCS).** `key_id = "hqv-ekyc-jcs"`. JCS dùng cách viết SỐ của JavaScript
(`1.0`→`1`, `0.5482`→`0.5482`), sort key, UTF-8 không escape → **mọi client JS/PHP tái tạo được
CHÍNH XÁC**. Chuỗi ký = `JCS({ "payload": <body-bỏ-signed>, "signed_at": …, "key_id": … })`.

**Python** (`pip install rfc8785`):
```python
import hashlib, hmac, rfc8785

def verify(obj: dict, signing_key: str) -> bool:
    signed = obj.get("signed") or obj["attestation"]["signed"]
    payload = obj.get("attestation", {}).get("payload") or {k: v for k, v in obj.items() if k != "signed"}
    msg = rfc8785.dumps({"payload": payload, "signed_at": signed["signed_at"], "key_id": signed["key_id"]})
    expect = hmac.new(signing_key.encode(), msg, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expect, signed["signature"])
# + kiểm ảnh: sha256(file_bytes) == obj["image_hashes"]["id_card"] / ["selfie"]
```

**TypeScript / Node** (`npm i canonicalize` — RFC 8785):
```ts
import canonicalize from "canonicalize";
import { createHmac, timingSafeEqual } from "crypto";

export function verify(obj: any, signingKey: string): boolean {
  const signed = obj.signed ?? obj.attestation?.signed;
  const { signed: _omit, ...rest } = obj;
  const payload = obj.attestation?.payload ?? rest;
  const msg = canonicalize({ payload, signed_at: signed.signed_at, key_id: signed.key_id })!;
  const expect = createHmac("sha256", signingKey).update(msg, "utf8").digest("hex");
  const a = Buffer.from(expect), b = Buffer.from(signed.signature);
  return a.length === b.length && timingSafeEqual(a, b);
}
```

**PHP** (JCS thuần, không cần thư viện):
```php
function jcs($v): string {
    if (is_array($v)) return '['.implode(',', array_map('jcs', $v)).']';   // JSON array
    if ($v instanceof stdClass) {                                          // JSON object
        $o = (array)$v; ksort($o, SORT_STRING);                            // sort key
        $p = [];
        foreach ($o as $k => $val)
            $p[] = json_encode((string)$k, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES).':'.jcs($val);
        return '{'.implode(',', $p).'}';
    }
    // scalar: PHP mặc định float 1.0->"1", 0.5482->"0.5482" (khớp JCS). ĐỪNG bật JSON_PRESERVE_ZERO_FRACTION.
    return json_encode($v, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES);
}
function verifyEkyc(string $rawBody, string $signingKey): bool {
    $o = json_decode($rawBody);                        // stdClass -> giữ {} khác []
    $signed  = $o->signed ?? $o->attestation->signed;
    $payload = $o->attestation->payload ?? (function($x){ $c = clone $x; unset($c->signed); return $c; })($o);
    $msg = jcs((object)['payload' => $payload, 'signed_at' => $signed->signed_at, 'key_id' => $signed->key_id]);
    return hash_equals(hash_hmac('sha256', $msg, $signingKey), $signed->signature);
}
```

> Nhờ JCS, **hết** cạm bẫy float `1.0` (JS/PHP viết số y hệt server). Chỉ còn 1 lưu ý PHP:
> `json_decode` phải cho ra **stdClass** (KHÔNG `true`) để giữ `{}` khác `[]`. Bản cũ `key_id`
> = `"hqv-ekyc-k1"` (canonical json.dumps Python) đã ngừng dùng.

### 6.1 · Chính sách ingest khuyến nghị (KHÔNG chặn khách thật)
JCS đã khớp số, nhưng vẫn nên phòng thủ nhiều lớp: chữ ký có thể lệch vì **lý do ngoài tầm
kiểm soát** (proxy/WAF sửa body, client cũ, sai `signing_key`...). Đừng trả `422` cứng — hãy:

| Trường hợp | Xử lý |
|---|---|
| Chữ ký **hợp lệ** + ảnh khớp `image_hashes` đã ký + field ngoài == `payload` | **Tin kết quả SDK**, ingest thẳng |
| Chữ ký **sai/thiếu/không kiểm được** | **KHÔNG chặn khách.** Bỏ toàn bộ claim của SDK; **server tự re-verify** bằng chính ảnh đã nhận (gọi lại `/ai/v1/verify` hoặc `/ocr/*` với ảnh đó) rồi dùng kết quả server |

→ Kẻ giả mạo không lợi gì (claim giả bị vứt, server đọc lại ảnh thật); khách thật không bao giờ
bị kẹt vì lỗi canonicalization. Đây là cách xử lý an toàn nhất cho tích hợp qua trình duyệt.

## 7. REST API (gọi trực tiếp / server-to-server)

Header bắt buộc: `X-API-KEY: <tenant key>`. Envelope `{ statusCode, message, object }`.
Gọi từ backend không cần Origin.

| Method · Path | multipart | object trả về |
|---|---|---|
| `POST /ai/v1/ocr/id` | `image` | trường CCCD/Căn cước + validity + raw_text |
| `POST /ai/v1/ocr/passport` | `image` | trường hộ chiếu (MRZ) |
| `POST /ai/v1/ocr/document` | `file` | OCR tài liệu tự do: ảnh hoặc PDF nhiều trang → `pages[].text`. Bật riêng theo tenant + quota ngày ¹ |
| `POST /ai/v1/ocr/document/async` | `file` | Như trên nhưng BẤT ĐỒNG BỘ: trả `job_id` ngay (202); kết quả cache RAM 1h ¹ |
| `GET /ai/v1/jobs/{job_id}` | — | Poll job: `status` = queued \| processing \| done \| error; khi done có `result` (đã ký) |
| `POST /ai/v1/chat` | `message`, `history?`, `file?` | Chat trợ lý AI on-device; kèm file thì OCR trước rồi làm theo yêu cầu ¹ |
| `POST /ai/v1/face/compare` | `img1`, `img2` | match, prob, similarity, quality_ok |
| `POST /ai/v1/liveness` | `image` | liveness, is_live, score |
| `POST /ai/v1/verify` | `id_card`, `selfie` | quyết định tổng hợp + chữ ký + tenant_id |
| `GET /ready` | — | mode, zero_storage, trạng thái model, license |

¹ Tính năng bật riêng theo tenant (opt-in) + quota ngày riêng; cần license bật module tương ứng.

```bash
curl -X POST https://ekyc.hqv.biz/ai/v1/verify \
  -H "X-API-KEY: hqv_live_xxxxxxxx" \
  -F id_card=@front.jpg -F selfie=@selfie.jpg
```

### 7.1 · OCR tài liệu bất đồng bộ (queue + cache)

Dành cho tài liệu lớn (PDF nhiều trang) hoặc khi app không muốn giữ kết nối chờ. Nộp
file → nhận `job_id` ngay → poll đến khi `done`. Job và kết quả chỉ nằm trong **RAM**,
tự xóa sau **1 giờ** (Stateless). Nộp lại đúng file đó (SHA-256 trùng, cùng tenant)
trong 1 giờ → trả ngay job cũ với `cached=true`.

```bash
# 1) Nộp  →  202
curl -X POST https://ekyc.hqv.biz/ai/v1/ocr/document/async \
  -H "X-API-KEY: hqv_live_xxxxxxxx" -F file=@hopdong.pdf
# {"object":{"job_id":"c4db57adf35e4866","status":"queued","sha256":"71d7...","expires_in_sec":3600}}

# 2) Poll (1-2s/lần)
curl https://ekyc.hqv.biz/ai/v1/jobs/c4db57adf35e4866 -H "X-API-KEY: hqv_live_xxxxxxxx"
# {"object":{"status":"done","ms":828,"result":{ "kind":..., "pages":[...], "id_card":null, "signed":{...} }}}
```

Trạng thái: `queued` (kèm `queue_size`) → `processing` → `done` (kèm `result` đã ký +
`ms`) hoặc `error`. HTTP 404 = sai id / khác tenant / quá TTL. HTTP 429 = hàng đợi đầy
hoặc hết quota ngày. Quota dùng chung với `/ocr/document`, tính lúc nộp.

### 7.2 · Chat với trợ lý AI on-device

Trợ lý chạy **ngay trên máy chủ HQV** (Gemma 4 qua MLX, Apple Silicon) — câu hỏi, tài
liệu và câu trả lời **không rời máy**, không gọi cloud AI nào. Server không lưu hội
thoại: app tự giữ lịch sử và gửi kèm mỗi lượt qua `history`. Đính kèm `file` (ảnh/PDF)
thì server OCR trước rồi trợ lý làm theo yêu cầu trên văn bản đó.

```bash
curl -X POST https://ekyc.hqv.biz/ai/v1/chat \
  -H "X-API-KEY: hqv_live_xxxxxxxx" \
  -F 'message=Giải thích giúp tôi biên lai này' \
  -F 'history=[{"role":"user","content":"chào"},{"role":"assistant","content":"Chào anh/chị!"}]' \
  -F file=@bienlai.jpg
# {"object":{"reply":"Đây là biên lai chuyển khoản...","model":"...gemma-4-e4b-it-4bit",
#            "ocr":{"kind":"image","ocr_pages":1,"id_card":null},"signed":{...}}}
```

Lượt gọi đầu tiên sau khi service khởi động chậm hơn (~10-20s nạp model); các lượt sau
nhanh. `history` giữ tối đa 8 lượt gần nhất. Trả lời tối đa ~1024 token. `message` tối
đa 4000 ký tự. Tính năng bật riêng theo tenant + quota ngày (mặc định 300 lượt/ngày).
HTTP 403 = tenant/license chưa bật; 429 = hết quota.

### 7.3 · Phiên eKYC chia sẻ qua LINK/QR (chuyển thiết bị) + callback

Dùng khi người dùng ở trên **máy tính không có camera tốt**, hoặc khi bạn muốn **gửi
link cho khách tự làm eKYC** (không cần đăng nhập). Tạo phiên → nhận **link + mã QR** →
khách mở trên điện thoại làm eKYC → kết quả **poll được** và/hoặc **push về callback**
của bạn (đã ký HMAC). App tự quyết dùng cách nào.

| Method · Path | Auth | object |
|---|---|---|
| `POST /ai/v1/sessions` | `X-API-KEY` (key tenant) | tạo phiên → `{session_id, url, qr, poll_url, expires_in_sec}` |
| `GET /ai/v1/sessions/{session_id}` | `X-API-KEY` (key tenant) | poll → `{status, result?, callback_status, expires_in_sec}` |
| `GET /s/{token}` | — (công khai) | trang eKYC nhúng cho khách (mở bằng link/QR, không cần đăng nhập) |

```bash
# 1) Tạo phiên (tùy chọn callback_url + options SDK cho trang nhúng)
curl -X POST https://ekyc.hqv.biz/ai/v1/sessions \
  -H "X-API-KEY: hqv_live_xxxxxxxx" -H "Content-Type: application/json" \
  -d '{"callback_url":"https://app.cua-ban.vn/ekyc/callback",
       "options":{"captureBack":true,"activeLiveness":"silent"}, "ttl_sec":900}'
# {"object":{"session_id":"dsXnFg96","url":"https://ekyc.hqv.biz/s/sess_...",
#            "qr":"data:image/png;base64,iVBORw0K...","poll_url":"/ai/v1/sessions/dsXnFg96",
#            "expires_in_sec":900}}

# 2a) Hiển thị url + qr cho khách (QR là data-URI PNG, nhúng thẳng <img src="...">)
# 2b) Poll đến khi status=done
curl https://ekyc.hqv.biz/ai/v1/sessions/dsXnFg96 -H "X-API-KEY: hqv_live_xxxxxxxx"
# {"object":{"status":"done","result":{ decision, document, validity, ... }, ...}}
```

`options` = mọi tùy chọn `HQVeKYC.open()` (§2) cho trang nhúng — vd `activeLiveness`,
`captureBack`, `theme`. `status`: `pending` → `running` → `done`. TTL mặc định 900s.

**Stateless cho phiên:** máy chủ lưu kết quả **theo id, KHÔNG kèm ảnh** (chỉ để poll/
đối soát, tự xóa sau TTL). **Ảnh chỉ được gửi ĐẦY ĐỦ MỘT LẦN qua callback** rồi mất khỏi
bộ nhớ — không ghi đĩa.

**Callback (webhook):** khi khách làm xong, máy chủ `POST` tới `callback_url`:
```
POST <callback_url>
X-HQV-Signature: <hex HMAC-SHA256 của raw body, khóa = callback_key>
X-HQV-Session: <session_id>
Body: { "session_id": "...", "result": { decision, document, files[](có dataUrl ảnh), attestation, ... } }
```
`callback_key` **tách miền** khỏi `signing_key` (dẫn xuất HKDF-SHA256) nên chữ ký callback
không trùng — và không dùng chéo được với — chữ ký kết quả. Verify:
```python
import hmac, hashlib
def callback_key(signing_key: str) -> bytes:
    prk = hmac.new(b"HQV-callback-v1", signing_key.encode(), hashlib.sha256).digest()
    return hmac.new(prk, b"callback-signature\x01", hashlib.sha256).digest()
def verify(raw_body: bytes, sig_hex: str, signing_key: str) -> bool:
    expect = hmac.new(callback_key(signing_key), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expect, sig_hex)
```
> `callback_url` phải là **http/https tới địa chỉ công khai** — máy chủ từ chối
> (chống SSRF) mọi đích nội bộ (localhost, IP LAN/nội bộ, link-local) và không theo redirect.
> `ttl_sec` bị kẹp trong khoảng `[60 … session_max_ttl_sec]`; body kết quả có trần kích thước.

**SDK (máy tính):** `HQVeKYC.handoff({ apiBase, apiKey, options, callbackUrl })` — tự tạo
phiên, hiện modal QR + link, poll đến khi xong rồi **trả về kết quả** (Promise):
```js
const kq = await HQVeKYC.handoff({
  apiBase: "https://ekyc.hqv.biz", apiKey: "hqv_live_xxxxxxxx",
  options: { activeLiveness: "silent" }
});
if (kq.status === "success" && kq.decision === "PASS") { /* ... */ }
```

## 8. Trạng thái & lỗi

| Trường | Giá trị |
|---|---|
| `status` | `success` · `cancelled` (người dùng đóng) · `declined` (không đồng ý điều khoản) |
| `decision` | `PASS` · `FAIL` |
| `reasons` | `face_mismatch` · `liveness_failed` · `document_expired` · `ocr_unreadable` |
| HTTP 403 | key sai, Origin không được phép, hoặc module chưa có trong license |
| HTTP 413 / 400 | ảnh quá lớn / ảnh hỏng |
| HTTP 429 | hết quota ngày, quá rate-limit, hoặc hàng đợi đầy |

## 9. Bảo mật & multi-tenant

### 9.1 · Hai loại khóa — dùng đúng chỗ

| Khóa | Tiền tố | Dùng ở | Kiểm soát |
|---|---|---|---|
| **Publishable** | `hqv_pk_` | **Trình duyệt / SDK JS** (công khai được) | **BẮT BUỘC** gửi kèm `Origin` khớp `allowed_origins`. Request không có Origin (curl/script) → **403**. |
| **Secret** | `hqv_sk_` | **Backend** (server-to-server) | Full quyền. Tùy chọn **IP allowlist** (chỉ nhận từ IP server bạn khai). **Không bao giờ** nhúng ra trình duyệt. |
| **Session token** | `sess_` | Trình duyệt (production) | Do backend phát bằng secret key (`POST /ai/v1/sessions`); TTL ngắn, dùng một lần. Xem §7.3. |

> **Vì sao tách?** Key trong JS là **công khai** — ai mở DevTools cũng thấy, không thể giấu.
> `Origin` do trình duyệt tự đặt (JS **không** ghi đè được), nhưng kẻ gian dùng `curl`/script
> có thể **giả hoặc bỏ** header Origin. Vì vậy:
> - **Publishable key** bị ép **phải** có Origin hợp lệ → chặn được lạm dụng qua công cụ ngoài trình duyệt.
> - Với **secret key**, đừng dựa vào Origin (server không gửi Origin) — hãy bật **IP allowlist**
>   (source IP trên TCP không giả được) và/hoặc giữ key hoàn toàn phía server.
>
> **Khuyến nghị production:** đừng nhúng cả secret lẫn publishable vào trang tĩnh không backend.
> Cho backend giữ **secret key**, gọi `POST /ai/v1/sessions` để phát **session token** ngắn hạn,
> rồi để trình duyệt chạy SDK bằng token đó. Key bền không bao giờ chạm client.

### 9.2 · Quản trị

- **IP allowlist (secret key):** admin đặt qua `PUT /admin/api/tenants/{id}/ip-allowlist`
  `{"ips":["203.0.113.5","198.51.100.0/24"]}`. `[]` = không giới hạn.
- **Cấp publishable key:** `POST /admin/api/tenants/{id}/publishable` (hiện đúng 1 lần);
  thu hồi: `DELETE` cùng đường dẫn.
- **Origin allowlist:** hỗ trợ khớp chính xác, `*.domain` (apex + mọi subdomain), `*` (chỉ dev).
- **Stateless:** máy chủ eKYC không lưu ảnh/PII; ứng dụng của bạn là bên quyết định
  lưu trữ (data controller).

Quản lý tenant & khóa tại `/admin`. Điều khoản & Chính sách dữ liệu: `/terms`.
