
# Sandbox & test IDs

Test your Myaza KYC integration end to end: webhooks, polling, the full result
payload (biodata, facial match, OCR data, captured-media URLs), and every failure
branch, all **without calling any government database or consuming paid verification credits**.

| Environment | Key prefix | Real IDs? | Test IDs? | Charged? |
|-------------|-----------|-----------|-----------|----------|
| **Sandbox** | `pk_test_` / `sk_test_` | rejected | canned result | never |
| Production | `pk_live_` / `sk_live_` | yes | treated as real | yes |

**Sandbox** accepts **only** published test IDs. A real (non-test) `idNumber` is rejected
immediately at `POST /verify` with `422 only_test_ids_allowed`.

All sandbox results are **fully fake**: fake biodata, fake government photos, fake OCR,
and fake gov-DB data. No external API is ever called.

## How test IDs work

Pass a test ID in the `idNumber` field of `POST /verify`. The **last one or two digits
encode the scenario**; everything before is zero-padding so the value stays format-valid
for its ID type. Example for NG BVN:

```
00000000001  → verified
00000000002  → not_found
00000000004  → selfie_mismatch
00000000007  → document_expired   (document IDs only)
00000000012  → provider_error
```

### Scenario reference

Scenarios 5 and 6 follow your workflow's identity details policy. When the workflow records every field the scenario disagrees on (5: first and last name; 6: first name and date of birth) instead of failing on it, the test ID verifies with `dataMatch: false`, exactly as a real mismatch would.

| Idx | Scenario | Status | `reasonCode` | Applies to |
|----:|----------|--------|--------------|-----------|
| 1 | verified | verified | — | all |
| 2 | not_found | not_found | `identity_not_found` | all |
| 3 | gov_validation_failed | failed | `gov_validation_failed` | all |
| 4 | selfie_mismatch | failed | `selfie_mismatch` | all |
| 5 | gov_data_mismatch | failed | `gov_data_mismatch` | all |
| 6 | document_data_mismatch | failed | `document_data_mismatch` | document |
| 7 | document_expired | failed | `document_expired` | document |
| 8 | document_type_mismatch | failed | `document_type_mismatch` | document |
| 9 | document_unreadable | failed | `document_unreadable` | document |
| 10 | document_blurry | failed | `document_blurry` | document |
| 11 | id_number_not_found | failed | `id_number_not_found` | document |
| 12 | provider_error | error | `provider_error` | all |
| 13 | unsupported_id_type | error | `unsupported_id_type` | all |
| 14 | media_not_found | error | `media_not_found` | all |
| 15 | insufficient_credits | error | `insufficient_credits` | all |
| 16 | system_error | error | `system_error` | all |
| 17 | id_type_not_enabled | error | `id_type_not_enabled` | all |
| 18 | document_verification_disabled | error | `document_verification_disabled` | document |
| 19 | gov_db_check_disabled | error | `gov_db_check_disabled` | all |
| 20 | sandbox_not_found | not_found | `sandbox_not_found` | all |
| 21 | sandbox_failed | failed | `sandbox_failed` | all |
| 22 | age_restricted | failed | `age_restricted` | all |
| 23 | age_unverified | failed | `age_unverified` | all |
| 24 | document_photo_no_face | failed | `document_photo_no_face` | document |

Scenarios 22 and 23 follow your workflow's age restriction. Scenario 22 returns a date of birth just outside your own limits (a year under the minimum, or a year over the maximum) and scenario 23 returns none, so both fail exactly as a real holder would. On a workflow with no age restriction, both verify. If your workflow sends that case to review instead of declining it, scenario 22 verifies with the out-of-range date of birth and your decision rule sends it to review; scenario 23 always fails.

Scenario 24 follows your workflow's settings for the photo on the document. It stands for a document ID whose selfie could only be compared with the photo printed on the document, where no face could be read on that photo. If your workflow compares that ID with the photo on the document and declines the case, it fails with `document_photo_no_face`. If your workflow sends that case to review, it verifies with `facialMatchSkipped: "no_face_on_document"`, and your decision rule on `verification.documentPhotoNoFace` sends it to review. If your workflow does not compare that ID with the photo on the document, it verifies.

> Document-only scenarios (6–11, 18) are not published for number-only IDs (BVN/NIN/vNIN).

### The `verified` test ID per ID type

Change the trailing digits per the table above to pick a different scenario.

| Country | ID type | Format | `verified` test ID |
|---------|---------|--------|--------------------|
| NG | bvn / nin | 11 digits | `00000000001` |
| NG | vnin | 16 chars | `0000000000000001` |
| NG | passport | A + 8 digits | `A00000001` |
| NG | drivers-license | TST + 8 digits | `TST00000001` |
| NG | pvc | 19 digits | `0000000000000000001` |
| GH | ghana-card | GHA-NNNNNNNNN-N | `GHA-000000001-0` |
| GH | voters | 10 digits | `0000000001` |
| GH | ssnit | C + 12 digits | `C000000000001` |
| GH | passport | G + 8 digits | `G00000001` |
| KE | national-id | 8 digits | `00000001` |
| KE | passport | K + 8 digits | `K00000001` |
| ZA | national-id | 13 digits | `0000000000001` |
| ZA | passport | Z + 8 digits | `Z00000001` |
| CI | cni | CI + 9 digits | `CI000000001` |
| CI | residence-card | CR + 9 digits | `CR000000001` |

## Example: number-only ID

```bash
curl -X POST https://your-server/api/kyc/verify \
  -H "Authorization: Bearer pk_test_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "country": "NG",
    "idType": "bvn",
    "idNumber": "00000000001",
    "userData": { "firstName": "Chidi", "lastName": "Okafor", "dateOfBirth": "1990-04-12" },
    "metadata": { "requestId": "test-001" }
  }'
```

## Document IDs

In **sandbox**, OCR is **never run**; a fake OCR result is returned instead. Either let
the SDK document flow default to the **verified** fixture (no ID number collected), or
pick a scenario by passing the document test ID in `idNumber` (e.g. `A00000007` for an
expired passport) or via the `sandboxOutcome` override below.

## Business (KYB)

Business verification never calls a real registry outside production: a registry lookup
incurs an external check cost in whichever environment requests it, so sandbox and development
serve canned data instead.

- **Any registration number is accepted** and resolves to a verified fixture that echoes
  the name you searched for. The catalogue values still pick their scenario:
  `RC0000001` is verified, `RC0000002` is not found. A business picked from the real
  company search (which does work in every environment) can therefore be checked on
  sandbox against fixture data.
- **`sandboxOutcome`** on the submit (`"verified"` or `"not_found"`) pins the result for
  any number, and is ignored silently in production.
- **The sandbox company carries a full key-people set** for exercising [due
  diligence](https://trust.myaza.co/documentation/key-people/markdown): a 60% beneficial owner, directors, a signatory, a persona
  named "Pep" that the sandbox screening stub flags (set `SCREENING_PROVIDER=stub`),
  and a **corporate shareholder**, `Sandbox Holdings Ltd` (RC0000900), whose own
  register resolves to a person majority-owner and a second company above it
  (RC0000901), so ownership-chain look-through, effective percentages, and the depth
  limit are all testable at no cost.

## Address presence

Presence verification has its own personas and a compressed clock so a multi-day watch
is testable in minutes; they are documented with the feature on the [Address
Intelligence](https://trust.myaza.co/documentation/address-intelligence/markdown#sandbox-and-billing) page. In short: outside production the
watch window runs in **minutes**, and an `externalUserId` containing
`presence_verified`, `presence_fail` or `presence_inconclusive` seeds a canned
observation stream through the real scorer. A plain id gets no seed, so you can drive
the ingest endpoint yourself.

## Overrides

Both go in `metadata` and are ignored in production.

- **`sandboxOutcome`**: force any scenario regardless of the test ID, e.g.
  `"sandboxOutcome": "selfie_mismatch"`.
- **`sandboxDelayMs`**: how long `pending → completed` takes (0–60000 ms). Defaults to
  ~1200 ms; set `0` for an instant result.

## Captured media

Media uploaded under a test ID is kept for **3 days**, then the bytes are replaced with a
placeholder (an image for stills, a sample clip for videos). The `data.media` URLs in
webhooks keep working; they just serve the placeholder after the sweep.

## Webhook simulator

From a webhook endpoint's detail page (or `POST /api/dashboard/webhooks/:id/simulate`
with `{ "event": "verification.completed" }`), fire a realistic, correctly-signed sample
payload of any event at your endpoint to test signature verification and your handler,
without running a verification. It uses the real delivery path (HMAC signing + retry).

## Notes

- Sandbox verifications are **never charged**, even for the `insufficient_credits` scenario.
- Test IDs bypass the ID-type allowlist, so a new organisation can test immediately. Use
  the `id_type_not_enabled` scenario to simulate a revoked allowlist.
- Branch on the stable `reasonCode`, not the human-readable `reason`.
