smartcomply-web-sdk 1.0.69 → 1.0.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,8 +15,8 @@ SmartComply SDK is a **KYC-as-a-service** widget that businesses embed in their
15
15
 
16
16
  The SDK verifies that a person is **real**, **alive**, and **who they claim to be**:
17
17
 
18
- 1. **Identity verification** — Validates the user's BVN/NIN against government databases, or extracts data from uploaded documents via OCR
19
- 2. **Liveness detection** — Camera-based challenge-response (blink, turn head, open mouth) to prove the person is physically present — not a photo or video replay
18
+ 1. **Identity verification** — Validates the user's BVN/NIN against government databases, or extracts data from uploaded documents
19
+ 2. **Liveness detection** — A short passive camera scan (blink + natural head movement, one continuous window) to prove the person is physically present — not a photo or video replay
20
20
  3. **Face matching** — Compares the user's live selfie against:
21
21
  - The government-returned photo (BVN/NIN flow), or
22
22
  - The face on their uploaded document (passport/license flow)
@@ -25,8 +25,10 @@ The SDK verifies that a person is **real**, **alive**, and **who they claim to b
25
25
 
26
26
  | Flow | How it works | ID Types |
27
27
  |------|-------------|----------|
28
- | **Data verification** | User enters an ID number → backend validates against government database → face match against government photo | BVN, NIN |
29
- | **Document verification** | User uploads/captures a document photo OCR extracts data + expiry check → face match against document photo | Passport, National ID, Driver's License |
28
+ | **Data verification** | User enters an ID number → backend validates against government database → face match against government photo | BVN, NIN, and other data-only channels configured in your dashboard |
29
+ | **Document verification** | User captures/uploads a document photo (front, plus back if required or offered) → data is extracted + expiry checked → face match against document photo | Passport, National ID (NIN slip/card), Driver's License, Voter's Card, and other document channels configured in your dashboard |
30
+
31
+ Exactly which ID types are available depends on what's enabled for your account and country in the Adhere dashboard — fetch `config.channels` (see [Load Configuration](#load-configuration)) rather than hardcoding a list.
30
32
 
31
33
  ### End-to-End Flow
32
34
 
@@ -46,8 +48,8 @@ The SDK verifies that a person is **real**, **alive**, and **who they claim to b
46
48
  │ │ │
47
49
  │ ┌─────────┼──────────┐ │
48
50
  │ ▼ ▼ ▼ │
49
- │ Face Match OCR Gov DB Check
50
- (DeepFace) (document) (BVN/NIN)
51
+ │ Face Match Document Read Gov DB Check
52
+ (+ liveness) (extraction) (BVN/NIN)
51
53
  │ └─────────┼──────────┘ │
52
54
  │ ▼ │
53
55
  │ Webhook POST ──────────► YOUR SERVER │
@@ -64,7 +66,14 @@ npm install smartcomply-web-sdk
64
66
  ### Alternative: CDN (No Build Step)
65
67
 
66
68
  ```html
67
- <script src="https://cdn.jsdelivr.net/npm/smartcomply-web-sdk@1.0.3/dist/smartcomply.browser.js"></script>
69
+ <!-- Always latest version -->
70
+ <script src="https://cdn.jsdelivr.net/npm/smartcomply-web-sdk@latest/dist/smartcomply.browser.js"></script>
71
+
72
+ <!-- Or pin to a specific version (recommended for production) — replace X.Y.Z
73
+ with the version you've actually tested against. Current version:
74
+ https://www.npmjs.com/package/smartcomply-web-sdk -->
75
+ <script src="https://cdn.jsdelivr.net/npm/smartcomply-web-sdk@X.Y.Z/dist/smartcomply.browser.js"></script>
76
+
68
77
  <script>
69
78
  // SDK available as window.SmartComplySDK
70
79
  SmartComplySDK.SmartComplyFlow.open({
@@ -75,6 +84,8 @@ npm install smartcomply-web-sdk
75
84
  </script>
76
85
  ```
77
86
 
87
+ > Pin to a specific version in production to avoid unexpected breaking changes — `@latest` is convenient for prototyping only. See [npmjs.com/package/smartcomply-web-sdk](https://www.npmjs.com/package/smartcomply-web-sdk) for the current version number.
88
+
78
89
  ---
79
90
 
80
91
  ## Option 1: Drop-in Widget (Recommended)
@@ -117,9 +128,9 @@ That's it. The SDK will:
117
128
  1. Create a secure session with the Adhere backend
118
129
  2. Show a branded welcome screen (your brand name + theme from dashboard)
119
130
  3. Let the user select their country and ID type
120
- 4. Collect their BVN/NIN number **or** capture their document photo
121
- 5. Run face liveness detection (camera + challenge actions)
122
- 6. Record a video and submit everything to the backend
131
+ 4. Collect their BVN/NIN number **or** capture their document photo (front, and back if the document has/needs one)
132
+ 5. Run a passive face liveness scan (camera stays open for a few seconds — the user blinks and turns their head naturally, no discrete step-by-step prompts)
133
+ 6. Record a short clip and submit everything to the backend
123
134
  7. Show a result screen and call your `onComplete` callback
124
135
 
125
136
  ### What the User Sees
@@ -130,9 +141,21 @@ That's it. The SDK will:
130
141
  | 2 | Country | Select country (auto-skipped if only one country configured) |
131
142
  | 3 | ID Type | Choose from the channels you configured (e.g., BVN, NIN, Passport) |
132
143
  | 4a | ID Input | Enter BVN/NIN number → instant backend verification against government DB |
133
- | 4b | Document | Capture a photo of passport/ID card (camera or file upload) |
134
- | 5 | Liveness | Camera opens — user completes challenge actions (blink, turn head, open mouth) |
135
- | 6 | Done | "Verification Submitted" — user clicks Done, result arrives via webhook |
144
+ | 4b | Document | Capture/upload the document photo front required; back is required, optional, or not offered depending on the document type (see [Two-Sided Documents](#two-sided-documents)) |
145
+ | 5 | Liveness | Camera opens — a single passive scan window; the user blinks and turns their head naturally, no sequential prompts |
146
+ | 6 | Done | "Verification Submitted" — user clicks Done, final result arrives via webhook |
147
+
148
+ ### Two-Sided Documents
149
+
150
+ Some document types capture a back-side photo as well as the front:
151
+
152
+ | Document | Back side |
153
+ |----------|-----------|
154
+ | Passport | Never — front only |
155
+ | NIN (slip or card) | **Optional** — offered, with a "Skip" option, since not every NIN document has a usable back |
156
+ | Driver's License, Voter's Card, National ID Card | **Required** |
157
+
158
+ This is driven entirely by the `requires_back_side` flag the backend returns per channel in `config.channels` (see [Load Configuration](#load-configuration)) — the drop-in widget handles it automatically. If you're building your own UI (headless), pass both `document`/`id_file` (front) and `document_back` (back) to `liveness.create()`/`startCheck()` when you have one; `document_back` is always optional at the API level regardless of what the UI requires.
136
159
 
137
160
  ### `SmartComplyFlow.open()` Options
138
161
 
@@ -155,12 +178,21 @@ SmartComplyFlow.open({
155
178
 
156
179
  ### `onComplete` Result
157
180
 
181
+ `onComplete` fires as soon as the user finishes their part (the "Verification Submitted" screen renders) — **not** when verification is actually decided. Backend processing (face match, OCR, government DB check) continues after this fires; the pass/fail outcome only ever arrives via [webhook](#receiving-results-webhook).
182
+
158
183
  ```typescript
159
184
  {
160
- entryId: 42, // Use this to track the verification
161
- status: "processing", // Always "processing" final result via webhook
162
- submittedAt: "2026-04-13T...", // ISO timestamp
163
- verificationResult: { // Only for data verification (BVN/NIN)
185
+ entryId: 42, // Use this to track the verification — matches
186
+ // verification_id in the webhook payload
187
+ sessionId: "da7623bd-9158-4b56-...", // The session token used for this flow
188
+ status: "processing", // Always "processing" this is a submission
189
+ // receipt, not a verification verdict
190
+ submittedAt: "2026-04-13T...", // ISO timestamp
191
+ verificationResult: { // Only present for data verification (BVN/NIN) —
192
+ // the immediate government-DB lookup result.
193
+ // This confirms the ID number matched a real
194
+ // record; it says nothing about the face match,
195
+ // which is still pending at this point.
164
196
  status: "success",
165
197
  code: "VERIFICATION_COMPLETE",
166
198
  data: { first_name: "Amara", last_name: "Okafor", identity_check_id: 123, ... }
@@ -210,14 +242,17 @@ console.log(config.verification_type); // ["data_verification"]
210
242
  console.log(config.channels);
211
243
  // {
212
244
  // "nigeria": [
213
- // { id: 8, name: "National Identity Number (NIN)", fields: [{ type: "input", label: "Identification Number" }] },
214
- // { id: 2, name: "Bank Verification Number Advanced (BVN)", fields: [{ type: "input", label: "Bank Verification Number" }] }
245
+ // { id: 8, name: "National Identity Number (NIN)", fields: [{ type: "input", label: "Identification Number" }], requires_back_side: "optional" },
246
+ // { id: 2, name: "Bank Verification Number Advanced (BVN)", fields: [{ type: "input", label: "Bank Verification Number" }] },
247
+ // { id: 5, name: "Driver's License", fields: [{ type: "upload", label: "Document" }], requires_back_side: true }
215
248
  // ]
216
249
  // }
217
250
  ```
218
251
 
219
252
  Use `config.channels` to build your own country/ID type selector. Each channel's `fields` array tells you what inputs to render.
220
253
 
254
+ For a document-verification channel, `requires_back_side` tells you whether to also collect a back-side photo: `true` (required), `false`/absent (never), or the string `"optional"` (offer it, but the user can skip — see [Two-Sided Documents](#two-sided-documents)).
255
+
221
256
  ### Verify Identity (Data Verification)
222
257
 
223
258
  For BVN/NIN — validates against the government database:
@@ -245,14 +280,16 @@ const result = await sdk.liveness.startCheck(container, {
245
280
  identifier: "12345678901", // The ID number entered by the user
246
281
  identifier_type: "NIN",
247
282
  country: "NG",
248
- id_file: documentBlob, // Optional: document photo (for document flow)
249
- identity_check: identityCheckId, // Optional: from verify response (for data flow)
250
- }, ["BLINK", "TURN_LEFT", "OPEN_MOUTH"]);
283
+ document: documentFrontBlob, // Optional: document front photo (document flow)
284
+ document_back: documentBackBlob, // Optional: document back photo, if the channel's
285
+ // requires_back_side is true or "optional"
286
+ identity_check: identityCheckId, // Optional: from verify response (data flow)
287
+ }, ["BLINK", "TURN_HEAD"]);
251
288
 
252
289
  console.log(result.status); // "processing"
253
290
  ```
254
291
 
255
- The SDK handles camera access, face detection, action prompts, video recording, and submission.
292
+ The camera UI runs a single passive scan (the user blinks and turns their head naturally within one window) rather than stepping through discrete prompts. The `actions` array (3rd argument, defaults to `["BLINK", "TURN_HEAD"]`) is sent to the backend as a descriptive tag — shown on your Adhere dashboard — not a live command sequence the UI enforces. The SDK handles camera access, face detection, video recording, and submission end to end.
256
293
 
257
294
  ### Liveness Check (fully manual)
258
295
 
@@ -264,9 +301,12 @@ const entry = await sdk.liveness.create({
264
301
  identifier: "A12345678",
265
302
  identifier_type: "Passport",
266
303
  country: "US",
267
- challenge_actions: ["BLINK", "TURN_LEFT", "OPEN_MOUTH"],
304
+ challenge_actions: ["BLINK", "TURN_HEAD"], // Descriptive tag, not a live prompt sequence
268
305
  autoshot_file: selfieBlob, // Captured selfie (JPEG/PNG, max 5MB)
269
- id_file: passportPhotoBlob, // Optional: document photo
306
+ document: passportFrontBlob, // Optional: document front photo
307
+ document_back: passportBackBlob, // Optional: document back photo (rarely needed —
308
+ // most document types are front-only; see
309
+ // Two-Sided Documents above)
270
310
  identity_check: identityCheckId, // Optional: from verify response
271
311
  });
272
312
 
@@ -284,121 +324,236 @@ const result = await sdk.liveness.submit(entry.id, videoBlob, snapshotBlob);
284
324
 
285
325
  ## Receiving Results (Webhook)
286
326
 
287
- Verification is processed asynchronously. After the user submits, the backend runs face matching using AI (DeepFace with RetinaFace detection) and delivers results via webhook to the URL configured in your SDK Config.
327
+ Verification is processed asynchronously. After the user submits, the backend runs face matching, OCR (for documents), and the government DB check (for BVN/NIN), then delivers one `liveness.completed` webhook to the URL configured in your SDK Config once everything is done.
328
+
329
+ The payload shape is the same for both verification types — `verification_type` tells you which one it was, and `document` is only present for document verification.
288
330
 
289
331
  ### Webhook Payload — Data Verification (BVN/NIN)
290
332
 
291
333
  ```json
292
334
  POST https://your-server.com/webhook
293
335
  Content-Type: application/json
294
- X-Adhere-Signature: <hmac-sha256-hex>
336
+ X-Adhere-Signature: sha256=<hmac-sha256-hex>
295
337
 
296
338
  {
297
339
  "event": "liveness.completed",
298
- "data": {
299
- "entry_id": 42,
300
- "status": "passed",
301
- "is_verified": true,
302
- "match_score": 0.15,
303
- "confidence_percentage": 62.5,
304
- "identity_face_match": {
340
+ "verification_id": 42,
341
+ "verification_type": "data_verification",
342
+ "status": "passed",
343
+ "failure_reason": null,
344
+ "timestamp": "2026-08-08T10:15:00.000Z",
345
+ "subject": {
346
+ "identifier": "12345678901",
347
+ "identifier_type": "National Identity Number (NIN)",
348
+ "country": "nigeria"
349
+ },
350
+ "biometrics": {
351
+ "liveness_verified": true,
352
+ "face_match": {
353
+ "attempted": true,
305
354
  "verified": true,
306
- "distance": 0.12,
307
- "threshold": 0.4,
308
355
  "confidence_percentage": 70.0
309
356
  },
310
- "metadata": { "ip_address": "...", "user_agent": "..." }
357
+ "selfie_url": "https://.../autoshot.jpg",
358
+ "face_analysis": {
359
+ "gender": "Female",
360
+ "dominant_emotion": "neutral",
361
+ "face_quality": {
362
+ "face_detected": true,
363
+ "face_confidence": 0.98,
364
+ "blur_score": 142.3,
365
+ "is_blurry": false
366
+ }
367
+ }
368
+ },
369
+ "activity": {
370
+ "session_id": "da7623bd-9158-4b56-a9e4-4bccf3c0133f",
371
+ "started_at": "2026-08-08T10:12:00.000Z",
372
+ "submitted_at": "2026-08-08T10:14:30.000Z",
373
+ "completed_at": "2026-08-08T10:15:00.000Z",
374
+ "duration_seconds": 180
375
+ },
376
+ "request_context": {
377
+ "ip": { "address": "102.67.1.66", "city": "Lagos", "country_code": "NG", "...": "..." },
378
+ "device": { "user_agent": "Mozilla/5.0 ...", "type": "desktop", "os": "Windows", "...": "..." }
379
+ },
380
+ "customer_profile": {
381
+ "first_name": "AMARA",
382
+ "last_name": "OKAFOR",
383
+ "other_name": null,
384
+ "date_of_birth": "01-Jan-1997",
385
+ "age": 29,
386
+ "gender": "Female",
387
+ "id_number": "12345678901",
388
+ "serial_number": null,
389
+ "occupation": null,
390
+ "place_of_birth": null,
391
+ "place_of_live": "...",
392
+ "date_of_issue": null,
393
+ "photo_url": null
311
394
  }
312
395
  }
313
396
  ```
314
397
 
315
- ### Webhook PayloadDocument Verification (Passport/License)
398
+ `photo_url` is always `null` here the government-returned photo isn't exposed in the webhook (it's already used server-side for face matching; the `biometrics.selfie_url` field above is the user's own selfie, not the reference photo).
399
+
400
+ ### Webhook Payload — Document Verification (Passport/License/NIN card)
401
+
402
+ Same top-level shape, plus a `document` block instead of (or alongside) `customer_profile`:
316
403
 
317
404
  ```json
318
405
  {
319
406
  "event": "liveness.completed",
320
- "data": {
321
- "entry_id": 43,
322
- "status": "passed",
323
- "is_verified": true,
324
- "match_score": 0.18,
325
- "confidence_percentage": 55.0,
326
- "document_verification": {
327
- "status": "verified",
328
- "document_type": "passport",
329
- "is_expired": false,
330
- "face_match_verified": true,
331
- "face_match_score": 0.22,
332
- "extracted_name": "AMARA OKAFOR",
333
- "extracted_expiry_date": "2030-06-15"
334
- },
335
- "metadata": { "ip_address": "...", "user_agent": "..." }
407
+ "verification_id": 43,
408
+ "verification_type": "document_verification",
409
+ "status": "passed",
410
+ "failure_reason": null,
411
+ "timestamp": "2026-08-08T10:20:00.000Z",
412
+ "subject": {
413
+ "identifier": "A12345678",
414
+ "identifier_type": "Passport",
415
+ "country": "usa"
416
+ },
417
+ "biometrics": { "...": "same shape as the data-verification example above" },
418
+ "activity": { "...": "same shape as the data-verification example above" },
419
+ "request_context": { "...": "same shape as the data-verification example above" },
420
+ "document": {
421
+ "status": "verified",
422
+ "document_type": "passport",
423
+ "is_expired": false,
424
+ "first_name": "AMARA",
425
+ "last_name": "OKAFOR",
426
+ "date_of_birth": "1997-01-01",
427
+ "age": 29,
428
+ "gender": "Female",
429
+ "nationality": "NGA",
430
+ "place_of_birth": "LAGOS",
431
+ "document_number": "A12345678",
432
+ "expiry_date": "2030-06-15",
433
+ "issue_date": "2020-06-15",
434
+ "issuing_authority": "...",
435
+ "place_of_issue": null,
436
+ "address": null,
437
+ "district": null,
438
+ "division": null,
439
+ "location": null,
440
+ "sub_location": null,
441
+ "serial_number": null,
442
+ "barcode_number": null,
443
+ "document_url": "https://.../document.jpg",
444
+ "document_back_url": null,
445
+ "face_match": {
446
+ "attempted": true,
447
+ "verified": true,
448
+ "confidence_percentage": 55.0,
449
+ "threshold_percentage": 35.0,
450
+ "reason": null,
451
+ "selfie_url": "https://.../autoshot.jpg",
452
+ "document_face_url": "https://.../document_face.jpg"
453
+ }
336
454
  }
337
455
  }
338
456
  ```
339
457
 
340
- ### Understanding the Scores
458
+ <details>
459
+ <summary>Every field on <code>document</code></summary>
460
+
461
+ Most of these are extracted from the document and will be `null` if the document type doesn't carry that field (e.g. `serial_number`/`barcode_number` mainly apply to newer Kenyan ID cards) or OCR couldn't read it:
462
+
463
+ `status`, `document_type`, `is_expired`, `first_name`, `last_name`, `date_of_birth`, `age`, `gender`, `nationality`, `place_of_birth`, `document_number`, `expiry_date`, `issue_date`, `issuing_authority`, `place_of_issue`, `address`, `district`, `division`, `location`, `sub_location`, `serial_number`, `barcode_number`, `document_url`, `document_back_url`, `face_match` (`attempted`, `verified`, `confidence_percentage`, `threshold_percentage`, `reason`, `selfie_url`, `document_face_url`).
464
+
465
+ `face_match.reason` is populated when `verified` is `false` or the match was skipped — a user-facing explanation (e.g. "Face similarity is below the match threshold").
341
466
 
342
- - **`match_score`** (distance) — Raw distance between face embeddings. Lower = more similar. Threshold is 0.4 (Facenet default).
343
- - **`confidence_percentage`** — Human-readable score: how close the match is to perfect. 100% = identical, 0% = at the threshold boundary.
344
- - **`identity_face_match`** — Only present in data verification (BVN/NIN). Confirms the person doing liveness matches the face on the government database.
345
- - **`document_verification`** — Only present in document verification. Includes OCR results and document-to-selfie face match.
467
+ </details>
468
+
469
+ ### Field Notes
470
+
471
+ - **`verification_id`** — matches the `entryId` your `onComplete` callback received when the user submitted.
472
+ - **`status`** — unlike `onComplete`'s `status` (always `"processing"`), this is terminal — but it means **"the check ran to completion," not "the person matched."** A face mismatch, low confidence score, or expired document does **not** set `status` to `"failed"` — it's still `"passed"` with the real outcome recorded deeper in the payload. `status: "failed"` is reserved for cases where the check itself couldn't run (service error, no selfie captured, government DB rejection). **Always check `biometrics.face_match.verified` (and `document.is_expired`, for document verification) to know whether the person actually passed — never gate access on `status` alone.**
473
+ - **`biometrics.face_match`** — present for every verification type. `attempted: false` means face matching was skipped (e.g. a document type with no face, like a CAC certificate or NIN slip) — `verified`/`confidence_percentage` are `null` in that case, meaning "not applicable," not a failure. When `attempted: true`, `verified: false` is the real "face did not match" signal.
474
+ - **`biometrics.face_analysis`** — supplementary selfie diagnostics (emotion, blur/quality), `null`/absent if analysis failed or no selfie was captured. `gender` here is sourced from the verified document/government record, not estimated from the selfie itself. There is no age estimate — age (where shown) is calculated from the document's date of birth, not the photo.
475
+ - **`customer_profile`** — only present for data verification, sourced from the government DB response.
476
+ - **`document`** — only present for document verification, sourced from OCR + the document-to-selfie face match. Check `document.is_expired` separately — an expired document can still have `verified: true` on the face match.
477
+ - **`request_context`** — IP/device metadata captured at session start; useful for fraud signals (e.g. `ip.country_matches_document`).
346
478
 
347
479
  ### Possible Statuses
348
480
 
349
481
  | Status | Meaning |
350
482
  |--------|---------|
351
- | `passed` | All checks passedperson is verified |
352
- | `failed` | Face mismatch, expired document, or processing error |
353
- | `processing` | Still being processed (rare — usually completes in seconds) |
483
+ | `passed` | The check ran to completion inspect `biometrics.face_match.verified` (and `document.is_expired` for documents) for the real outcome |
484
+ | `failed` | The check itself could not complete — service error, no selfie/document captured, or a government DB rejection. See `failure_reason` for a user-safe description |
354
485
 
355
486
  ### Verify Signature
356
487
 
357
- **Always verify the webhook signature** to prevent spoofing:
488
+ **Always verify the webhook signature** to prevent spoofing. Two details matter here: the header value is prefixed with `sha256=`, and the signature is computed over the **exact compact-JSON bytes** the server sent (no extra whitespace) — so you must use the **raw request body**, not a re-serialized copy of the parsed object, or the HMAC won't match.
358
489
 
359
490
  ```javascript
360
491
  const crypto = require("crypto");
361
492
 
362
- app.post("/webhook/smartcomply", express.json(), (req, res) => {
363
- const signature = req.headers["x-adhere-signature"];
364
- const secret = process.env.WEBHOOK_SECRET.replace(/-/g, "");
365
- const expected = crypto
366
- .createHmac("sha256", secret)
367
- .update(JSON.stringify(req.body))
368
- .digest("hex");
369
-
370
- const isValid = crypto.timingSafeEqual(
371
- Buffer.from(signature, "hex"),
372
- Buffer.from(expected, "hex")
373
- );
374
-
375
- if (!isValid) return res.status(401).send("Bad signature");
376
-
377
- // Handle the event
378
- const { event, data } = req.body;
379
-
380
- if (event === "liveness.completed" && data.is_verified) {
381
- // User is verified — update your database
382
- markUserAsVerified(data.entry_id);
493
+ // IMPORTANT: register this route with a raw-body parser (not express.json()),
494
+ // or capture the raw body via a verify callback — re-stringifying req.body
495
+ // after JSON.parse produces different bytes and the signature will never match.
496
+ app.post(
497
+ "/webhook/smartcomply",
498
+ express.json({
499
+ verify: (req, res, buf) => { req.rawBody = buf; },
500
+ }),
501
+ (req, res) => {
502
+ const header = req.headers["x-adhere-signature"] || "";
503
+ const signature = header.replace(/^sha256=/, "");
504
+ const secret = process.env.WEBHOOK_SECRET.replace(/-/g, "");
505
+ const expected = crypto
506
+ .createHmac("sha256", secret)
507
+ .update(req.rawBody)
508
+ .digest("hex");
509
+
510
+ const isValid =
511
+ signature.length === expected.length &&
512
+ crypto.timingSafeEqual(Buffer.from(signature, "hex"), Buffer.from(expected, "hex"));
513
+
514
+ if (!isValid) return res.status(401).send("Bad signature");
515
+
516
+ const { event, verification_id, status, biometrics, document } = req.body;
517
+
518
+ // status === "passed" only means the check ran to completion — it is
519
+ // NOT the same as "the person is verified". Check the real outcome:
520
+ const faceMatched = biometrics?.face_match?.attempted
521
+ ? biometrics.face_match.verified === true
522
+ : true; // not attempted (e.g. NIN slip, CAC) — nothing to fail here
523
+ const documentOk = document ? document.is_expired === false : true;
524
+
525
+ if (event === "liveness.completed" && status === "passed" && faceMatched && documentOk) {
526
+ // ✓ User is verified — update your database
527
+ markUserAsVerified(verification_id);
528
+ } else if (event === "liveness.completed") {
529
+ // Check completed but didn't pass verification (face mismatch, expired
530
+ // document) — or status === "failed" (the check itself couldn't run).
531
+ // Either way, do not treat this as a verified user.
532
+ recordVerificationOutcome(verification_id, req.body);
533
+ }
534
+
535
+ res.json({ received: true });
383
536
  }
384
-
385
- res.json({ received: true });
386
- });
537
+ );
387
538
  ```
388
539
 
389
540
  ---
390
541
 
391
542
  ## Challenge Actions
392
543
 
393
- | Action | What the user does |
544
+ The built-in camera UI (`SmartComplyFlow.open()` and `liveness.startCheck()`) runs a **single passive scan** — the user blinks and turns their head naturally within one continuous window, with no discrete step-by-step prompts. There is no server-side per-action scoring; liveness is confirmed by the scan as a whole (blink + motion detected during the window), not by ticking off each action in `challenge_actions` individually.
545
+
546
+ The `challenge_actions`/`actions` array you pass to `liveness.create()` or `startCheck()` is sent to the backend as a **descriptive tag only** — it's what shows up in the "Challenge Actions" column on your Adhere dashboard for that verification. It does not change what the widget's camera UI actually asks the user to do.
547
+
548
+ | Action | Used to describe |
394
549
  |--------|-------------------|
395
- | `BLINK` | Blink both eyes |
396
- | `TURN_LEFT` | Turn head to the left |
397
- | `TURN_RIGHT` | Turn head to the right |
398
- | `TURN_HEAD` | Turn head in any direction |
399
- | `OPEN_MOUTH` | Open mouth wide |
550
+ | `BLINK` | The user blinked during the scan |
551
+ | `TURN_HEAD` | The user turned their head during the scan |
552
+ | `TURN_LEFT` | Legacy — turned head left (older multi-step challenge design) |
553
+ | `TURN_RIGHT` | Legacy — turned head right (older multi-step challenge design) |
554
+ | `OPEN_MOUTH` | Legacy — opened mouth (older multi-step challenge design) |
400
555
 
401
- Actions must be **UPPERCASE**. We recommend using 3 actions: `["BLINK", "TURN_LEFT", "OPEN_MOUTH"]`.
556
+ Actions must be **UPPERCASE**. Default (and recommended) is `["BLINK", "TURN_HEAD"]` — it's what the widget actually does and what gets tagged accurately.
402
557
 
403
558
  ---
404
559
 
@@ -425,9 +580,11 @@ try {
425
580
 
426
581
  | Error Code | HTTP | Meaning |
427
582
  |------------|------|---------|
428
- | `INVALID_API_KEY` | 401 | Bad API key or expired session |
583
+ | `INVALID_API_KEY` | 401 | Bad/missing `apiKey`, malformed `Authorization` header, or the branch isn't enabled for onboarding — only ever returned at session creation (`createSession()`), not after |
584
+ | `INVALID_SESSION` | 401 | Session token missing, malformed, expired (30 min), or already revoked — every call *after* `createSession()` uses this, not `INVALID_API_KEY`. Fix: call `createSession()` again; `apiKey`/`clientId` are unchanged and reused as-is |
429
585
  | `SDK_CONFIG_NOT_FOUND` | 404 | Invalid `clientId` |
430
586
  | `VALIDATION_ERROR` | 400 | Missing or invalid fields |
587
+ | `RETRY_LIMIT_EXCEEDED` | 429 | User exceeded the retry limit for confirmation/liveness attempts — they must restart with a new session |
431
588
  | `INSUFFICIENT_BALANCE` | 402 | Top up your wallet |
432
589
 
433
590
  ---
@@ -71,6 +71,16 @@ export declare class DocumentCapture {
71
71
  private _checkPhotoQuality;
72
72
  /** Decodes to a canvas, optionally downscaled to maxDim on the long edge (never upscales). */
73
73
  private _blobToCanvas;
74
+ /**
75
+ * Rotate an image blob by a multiple of 90° and re-encode it — used by the
76
+ * preview screen's rotate control for a document photographed or uploaded
77
+ * sideways/upside-down. EXIF auto-rotation (see compressImage) only fixes
78
+ * phone photos that embed correct orientation metadata; it can't help a
79
+ * document whose actual pixel content is sideways (e.g. the phone itself
80
+ * was held rotated relative to the document, or a pre-cropped/scanned
81
+ * upload with no EXIF at all) — that needs the user to say so directly.
82
+ */
83
+ private _rotateImage;
74
84
  private handleCaptureComplete;
75
85
  private handleFileUploadComplete;
76
86
  private validateFile;
@@ -1 +1 @@
1
- {"version":3,"file":"DocumentCapture.d.ts","sourceRoot":"","sources":["../../../src/flow/DocumentCapture.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAgB,MAAM,SAAS,CAAC;AACpD,OAAO,EAAuC,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAQ3F;;;;;;;;GAQG;AACH,MAAM,WAAW,qBAAqB;IACpC,KAAK,EAAE,IAAI,CAAC;IACZ,IAAI,CAAC,EAAE,IAAI,CAAC;IAKZ,SAAS,EAAE,OAAO,CAAC;CACpB;AAED,qBAAa,eAAe;IAC1B,OAAO,CAAC,IAAI,CAA+B;IAC3C,OAAO,CAAC,OAAO,CAAiC;IAChD,OAAO,CAAC,WAAW,CAA+B;IAClD,OAAO,CAAC,aAAa,CAAsC;IAC3D,OAAO,CAAC,kBAAkB,CAA6B;IACvD,OAAO,CAAC,cAAc,CAA0D;IAChF,OAAO,CAAC,aAAa,CAAuC;IAC5D,OAAO,CAAC,cAAc,CAAS;IAC/B,OAAO,CAAC,SAAS,CAAqB;IACtC,OAAO,CAAC,QAAQ,CAAqB;IACrC,OAAO,CAAC,WAAW,CAA6B;IAChD,OAAO,CAAC,oBAAoB,CAAuC;IAEnE;;;OAGG;IACH,OAAO,CACL,SAAS,EAAE,WAAW,EACtB,KAAK,EAAE,WAAW,EAClB,YAAY,EAAE,MAAM,EACpB,YAAY,CAAC,EAAE,YAAY,GAC1B,OAAO,CAAC,qBAAqB,CAAC;IAiCjC,OAAO,IAAI,IAAI;IAuBf,OAAO,CAAC,eAAe;IAOvB,8EAA8E;IAC9E,OAAO,CAAC,qBAAqB;IAI7B,0EAA0E;IAC1E,OAAO,CAAC,kBAAkB;IAI1B,OAAO,CAAC,kBAAkB;IAqL1B,wDAAwD;IACxD,OAAO,CAAC,QAAQ;IAQhB,OAAO,CAAC,gBAAgB;IAyHxB;;;;;;OAMG;IACH,OAAO,CAAC,YAAY;IAQpB;;;;;OAKG;IACH,OAAO,CAAC,iBAAiB;IAOzB,OAAO,CAAC,mBAAmB;YAQb,UAAU;IAsExB,OAAO,CAAC,YAAY;IA4BpB,OAAO,CAAC,WAAW;IAqGnB;;;;;;OAMG;YACW,kBAAkB;IA4BhC,8FAA8F;IAC9F,OAAO,CAAC,aAAa;IAqBrB,OAAO,CAAC,qBAAqB;IAyC7B,OAAO,CAAC,wBAAwB;IAShC,OAAO,CAAC,YAAY;IAsBpB;;;;;;;;OAQG;YACW,sBAAsB;IASpC,OAAO,CAAC,gBAAgB;IAKxB,OAAO,CAAC,UAAU;IAUlB,OAAO,CAAC,gBAAgB;IAgBxB,OAAO,CAAC,SAAS;IAoCjB,OAAO,CAAC,YAAY;IA4BpB,OAAO,CAAC,aAAa;IA4ErB;;;OAGG;IACH,OAAO,CAAC,oBAAoB;IAuC5B,OAAO,CAAC,gBAAgB;CASzB"}
1
+ {"version":3,"file":"DocumentCapture.d.ts","sourceRoot":"","sources":["../../../src/flow/DocumentCapture.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAgB,MAAM,SAAS,CAAC;AACpD,OAAO,EAAuC,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAQ3F;;;;;;;;GAQG;AACH,MAAM,WAAW,qBAAqB;IACpC,KAAK,EAAE,IAAI,CAAC;IACZ,IAAI,CAAC,EAAE,IAAI,CAAC;IAKZ,SAAS,EAAE,OAAO,CAAC;CACpB;AAED,qBAAa,eAAe;IAC1B,OAAO,CAAC,IAAI,CAA+B;IAC3C,OAAO,CAAC,OAAO,CAAiC;IAChD,OAAO,CAAC,WAAW,CAA+B;IAClD,OAAO,CAAC,aAAa,CAAsC;IAC3D,OAAO,CAAC,kBAAkB,CAA6B;IACvD,OAAO,CAAC,cAAc,CAA0D;IAChF,OAAO,CAAC,aAAa,CAAuC;IAC5D,OAAO,CAAC,cAAc,CAAS;IAC/B,OAAO,CAAC,SAAS,CAAqB;IACtC,OAAO,CAAC,QAAQ,CAAqB;IACrC,OAAO,CAAC,WAAW,CAA6B;IAChD,OAAO,CAAC,oBAAoB,CAAuC;IAEnE;;;OAGG;IACH,OAAO,CACL,SAAS,EAAE,WAAW,EACtB,KAAK,EAAE,WAAW,EAClB,YAAY,EAAE,MAAM,EACpB,YAAY,CAAC,EAAE,YAAY,GAC1B,OAAO,CAAC,qBAAqB,CAAC;IAiCjC,OAAO,IAAI,IAAI;IAuBf,OAAO,CAAC,eAAe;IAOvB,8EAA8E;IAC9E,OAAO,CAAC,qBAAqB;IAI7B,0EAA0E;IAC1E,OAAO,CAAC,kBAAkB;IAI1B,OAAO,CAAC,kBAAkB;IAqL1B,wDAAwD;IACxD,OAAO,CAAC,QAAQ;IAQhB,OAAO,CAAC,gBAAgB;IAyHxB;;;;;;OAMG;IACH,OAAO,CAAC,YAAY;IAQpB;;;;;OAKG;IACH,OAAO,CAAC,iBAAiB;IAOzB,OAAO,CAAC,mBAAmB;YAQb,UAAU;IAsExB,OAAO,CAAC,YAAY;IA4BpB,OAAO,CAAC,WAAW;IAwInB;;;;;;OAMG;YACW,kBAAkB;IA4BhC,8FAA8F;IAC9F,OAAO,CAAC,aAAa;IAqBrB;;;;;;;;OAQG;IACH,OAAO,CAAC,YAAY;IA2BpB,OAAO,CAAC,qBAAqB;IAyC7B,OAAO,CAAC,wBAAwB;IAShC,OAAO,CAAC,YAAY;IAsBpB;;;;;;;;OAQG;YACW,sBAAsB;IASpC,OAAO,CAAC,gBAAgB;IAKxB,OAAO,CAAC,UAAU;IAUlB,OAAO,CAAC,gBAAgB;IAgBxB,OAAO,CAAC,SAAS;IAoCjB,OAAO,CAAC,YAAY;IA4BpB,OAAO,CAAC,aAAa;IA4ErB;;;OAGG;IACH,OAAO,CAAC,oBAAoB;IAuC5B,OAAO,CAAC,gBAAgB;CAazB"}