---
description: >-
  Testing an Inyo KYC integration in sandbox — driving approved, declined, and in_review outcomes, telling simulated results from real ones via the provider field, and sandbox access requirements.
---

# Sandbox & Test Data

Sandbox is a full copy of the verification pipeline with its own credentials and its own data. Use it to build and prove your integration before any real customer reaches it.

***

### Access

| What you need | How you get it |
| ------------- | -------------- |
| Sandbox base URL | Issued by Inyo at onboarding |
| `client_id` / `client_secret` | Issued by Inyo at onboarding, separate from production |
| `webhookSecret` | Issued by Inyo at onboarding, separate from production |
| Network access | Your egress IP ranges must be allowlisted — send them to your Inyo contact |
| A registered `webhookUrl` | Optional, but needed to exercise webhook delivery. A tunnel works during development |

Sandbox and production credentials are never interchangeable, and a sandbox verification is never a valid basis for a real onboarding decision.

***

### Sandbox Runs Real Verification

Sandbox performs **real document analysis** — the same extraction, authenticity, liveness, and face-match pipeline as production. No test document number produces a canned *verification* outcome the way test card numbers work for payments: approval, decline, and review all come from the images you submit.

The one exception is the document-number **format** check, which does accept reserved values — see [Reserved Test Document Numbers](#reserved-test-document-numbers). It checks the shape of a declared number and never looks at an image, so it is a separate surface from the verification pipeline described here.

The practical consequence: **test with real documents you personally control**, or with documents issued for testing. A verification will genuinely fail if the image is blurry, the document is expired, or the selfie does not match — which is exactly what makes sandbox useful for proving your error handling.

> Every result carries a `provider` field. `"inyo"` means real analysis produced it. `"inyo-mock"` means a simulator did — which never happens in production, and tells you unambiguously that a result did not come from real verification.

***

### Driving Each Outcome

| Target | How to produce it |
| ------ | ----------------- |
| `approved` | A valid, unexpired document you control, well lit and in frame, with a selfie of the same person |
| `declined` — expired document | An expired document. `document_not_expired` fails |
| `declined` — face mismatch | A document belonging to one person with a selfie of another. `face_match` fails |
| `declined` — screen recapture | Photograph the document off a phone or monitor screen. `document_scene_clear` fails |
| `declined` — unreadable | A deliberately blurry or partially covered capture. `document_readable` fails |
| `declined` — capture attempts | Fail capture repeatedly in the widget until the limit is reached, adding a `capture_attempts` check |
| `in_review` — soft check | Send `dataCheck: true` with a `prefill` name or date of birth that does not match the document. `document_data_match` fails |
| `in_review` — held rejection | Ask Inyo to enable held rejections on your sandbox tenant, then produce any readable-document decline |
| `in_review` — borderline pass | Ask Inyo to set a review threshold on your sandbox tenant above your face-match threshold, then verify with a marginal selfie |
| `expired` | Create a session and leave it unused past the link's 48-hour lifetime |
| `422` responses | Request a document type you do not have enabled, or send a malformed `prefill.documentNumber` |
| `502` | Not reproducible on demand — handle it as a transient retry path in code |

**Ask for a separate sandbox tenant if you need conflicting configurations.** Review routing and held rejections are tenant-level settings, so exercising both a normal decline and a held decline means changing configuration between runs — or having two sandbox tenants.

***

### Testing Without a Camera

The document-number format validator needs no images and consumes no verification, which makes it the fastest thing to integrate against first:

```bash
curl --request POST \
  --url https://{FQDN}/v1/validators/document-number \
  --header "Authorization: Bearer $ACCESS_TOKEN" \
  --header 'Content-Type: application/json' \
  --data '{"documentType": "drivers_license", "number": "not-a-number", "issuingCountry": "USA", "issuingState": "PA"}'
```

```json
{
  "documentType": "drivers_license",
  "issuingCountry": "USA",
  "issuingState": "PA",
  "valid": false,
  "rule": "dl:USA:PA",
  "detail": "document number does not match the PA license format"
}
```

Use it to confirm your handling of all three states — `true`, `false`, and `null` for an unknown jurisdiction. See [Document Number Validator](document-number-validator.md).

***

### Reserved Test Document Numbers

The document-number format check accepts a small set of reserved values that return a chosen verdict on demand. Reach for them when you need a specific `valid` outcome — most often from remittance [sender creation](../remittances/sender/README.md), which runs this check on every declared document — without first learning a jurisdiction's real number format.

**Sandbox only.** These values are enabled per environment; if the ones below return an ordinary verdict, ask your Inyo contact to enable them on your sandbox tenant.

Every capturable type publishes all three `valid` states, so you can exercise your own handling per type without learning a jurisdiction's real number format. `ssn` and `itin` carry a passing value only — see below.

| `documentType` | `issuingCountry` | `issuingState` | `number` | `valid` |
| --- | --- | --- | --- | --- |
| `drivers_license` | `USA` | `PA` | `99900001` | `true` |
| `drivers_license` | `USA` | `PA` | `99900002` | `false` |
| `drivers_license` | `USA` | `PA` | `99900003` | `null` |
| `drivers_license` | `BRA` | — | `99900000070` | `true` |
| `drivers_license` | `BRA` | — | `99900000188` | `false` |
| `drivers_license` | `BRA` | — | `99900000296` | `null` |
| `passport` | `USA` | — | `999000001` | `true` |
| `passport` | `USA` | — | `999000002` | `false` |
| `passport` | `USA` | — | `999000003` | `null` |
| `identity_card` | `MEX` | — | `XXXX000101HXXXXX01` | `true` |
| `identity_card` | `MEX` | — | `XXXX000101HXXXXX02` | `false` |
| `identity_card` | `MEX` | — | `XXXX000101HXXXXX03` | `null` |
| `ssn` | `USA` | — | `078051120` | `true` |
| `itin` | `USA` | — | `912891234` | `true` |

The `identity_card` rows are Mexican because there is no US identity-card rule to test against — a US value would be a test case for a check that does not exist. A CURP encodes the holder's name and birth date, so the all-`X` name field is structurally valid and cannot collide with a real person.

Send each number with the `issuingCountry` and `issuingState` shown. A reserved value is matched on that exact pairing, so the same number under any other jurisdiction is validated by the ordinary rules.

```bash
curl --request POST \
  --url https://{FQDN}/v1/validators/document-number \
  --header "Authorization: Bearer $ACCESS_TOKEN" \
  --header 'Content-Type: application/json' \
  --data '{"documentType": "drivers_license", "number": "99900002", "issuingCountry": "USA", "issuingState": "PA"}'
```

```json
{
  "documentType": "drivers_license",
  "issuingCountry": "USA",
  "issuingState": "PA",
  "valid": false,
  "rule": "reserved:drivers_license:PA:invalid",
  "detail": "reserved sandbox test value — simulated invalid verdict"
}
```

A simulated result carries the response header **`X-Inyo-Simulated: true`**. It is present only when a reserved value produced the result, and absent otherwise — including in production, where it never appears.

> **Reserved values are real, format-valid numbers for their jurisdiction.** That is deliberate: it means an upstream format rule — such as the one remittance sender creation applies before calling this check — passes the value through instead of rejecting it first, which is what lets `99900002` reach us and come back `false`.
>
> The consequence is that **where the reserved values are not enabled, they return the verdict the real format rules give them** — `valid: true` for every value except the ITIN, which returns `false`. A reserved value can never produce a stricter outcome than an ordinary number would, so leaving one in code is not a safety risk — but it will silently stop simulating, so do not carry one into production.
>
> **`912891234` is the only value that returns `false` with simulation off.** No voided ITIN specimen exists the way `078-05-1120` does for SSNs, so it is drawn from a group the IRS reserves for other programmes — the only way to guarantee it can never belong to a real person. The trade is that a strict upstream format rule may reject it before it reaches this check. If that happens on your side, use it against this endpoint directly rather than through a caller that pre-validates.

***

### Testing Result Delivery

Delivery is worth testing on its own, independently of verification outcomes:

1. **Signature verification** — capture one real sandbox webhook body and its `X-Inyo-Signature`, then unit-test your verifier against those exact bytes. Add a case that mutates one byte of the body and asserts rejection, and a case that re-serializes the parsed JSON and asserts rejection. Those two cases catch the mistake that breaks most integrations. Then add three that catch the ones you will only hit later: a stale `t=` outside your tolerance, a header carrying an extra `v2=` your code does not implement (it must still verify on `v1`), and a header repeating `v1=` twice (it must be rejected outright, not resolved by picking one).
2. **Retry behavior** — return `500` from your endpoint and confirm you see up to three attempts, then nothing.
3. **Ordering** — replay two stored notifications for one session out of order and assert your handler keeps the one with the higher `notifiedAt`.
4. **Idempotency** — deliver the same result twice and assert your system does not double-process it.

Steps 1, 3, and 4 need no sandbox call at all once you have captured one real payload.

***

### Before You Go Live

| Check | Why |
| ----- | --- |
| Signature verification runs against **raw bytes** | The most common production failure |
| `in_review` has a pending state in your model | It is neither approved nor rejected, and it arrives on redirects too |
| Concurrent results resolve by `notifiedAt` | Deliveries can arrive out of order |
| A completed outcome can be reversed | Quality-control overrides re-deliver a changed result |
| `502` retries rather than declining | It is an infrastructure failure, not a customer outcome |
| Production credentials are separate and the base URL is switched | Sandbox tokens are not valid in production |
| A reconciliation job polls non-terminal sessions | Delivery is best-effort; `GET /v1/sessions/{sessionId}` is authoritative |

***

### Next Steps

* [Getting Started](getting-started.md) — the end-to-end walkthrough
* [Receiving Results](results.md) — signatures, retries, and ordering
