Sandbox Testing
The sandbox lets you take a sender through identity verification and a transaction through its entire lifecycle, without real documents, reviewers or money movement. Four tools make this possible:
- Reserved document numbers that decide how a sender's KYC session ends.
- Resolve endpoints that settle a KYC session held for review, or a document upload left pending β which in sandbox nobody would otherwise pick up.
- Sender values that the payout network's own sandbox acts on.
- A force-event endpoint that simulates the external callbacks (gateway webhooks, payout-network notifications) that normally advance a transaction.
The page follows the order of an integration: senders first, then transactions.
Senders
Zipcode Validation
Sender creation validates the zipcode against its country. Two rejections are easy to hit by accident when generating test data:
| Zipcode | Country | Result |
|---|---|---|
99999, 00000, AAAAA | most | 422 - all-same-character values are refused as placeholder data |
ABC | US | 422 - does not match the country's postal format |
12345, 12345-6789 | US | accepted |
Whether placeholder values are allowed is configured per country, so a value refused for one country may be accepted for another.
Phone Verification
Phone identity-match is simulated in sandbox and driven by the last digit of the number, so you can reach every branch without changing any configuration. See Phone verification -> Sandbox testing for the full table.
Verifying a Sender's Identity
The whole KYC flow runs in sandbox, and you choose how each session ends:
- Declare a document on the sender, carrying a reserved number.
- The number decides the ending.
- Open the session and read its outcome from
DocumentUpdatedEvents. - If it is held for review, resolve it yourself.
In sandbox, every session that reaches a verdict is held for review β that is deliberate, not a misconfiguration. No approve or decline bar is set, so a clean document and a failed one alike report PENDING_REVIEW, and the verdict is the one you resolve. Only the endings without a verdict β FAILED, EXPIRED and SUPERSEDED β are terminal on their own.
Declaring the document
Sender creation format-checks every declared document against the Inyo KYC service and refuses a number the check answers invalid, so an invented number can fail the request before anything is persisted. The reserved numbers below are real, format-valid numbers, so they pass: the licence and passport values come back valid, and the US identity-card values come back unevaluated, which sender creation also accepts.
A reserved number is matched on its exact document type and jurisdiction. Declare it with countryCode: "US", and a licence with stateCode: "PA" as well:
"documents": [
{ "type": "drivers_license", "document": "99900101", "countryCode": "US", "stateCode": "PA" }
]
The session verifies the sender's first eligible document β a licence before a passport before an identity card β so the reserved number has to be on that one. Declaring a single document is the simplest way to be sure.
To test the format check itself rather than the session, use the reserved format-check numbers instead. They return a chosen true, false or null verdict and dictate no session; false is how you reach sender creation's own refusal.
Forcing each ending
The number on the declared document decides how the session ends. Numbers are listed for drivers_license, id and passport, in that order:
| Number | What the session does | What DocumentUpdatedEvents delivers |
|---|---|---|
clean β 99900101, 99900201, 999003001 | Completes at creation with a full result | PENDING, then VERIFIED or REJECTED once you resolve it |
capture_exhausted β 99900108, 99900208, 999003008 | Ends at creation as a sender who ran out of capture attempts | FAILED |
link_expired β 99900113, 99900213, 999003013 | Is created already expired; its link shows the expired-link message | EXPIRED |
| Any other number | Waits for a real capture in the widget, analysed as in production | Whatever the capture produces; EXPIRED if the sender does not finish within 49 hours |
SUPERSEDED needs no number: open a second session for the same sender, or re-declare the sender's document with a different number, and the earlier session ends SUPERSEDED. The three endings without a verdict are explained in When a session ends without a verdict.
The other verdict scenarios in the catalog β document_not_authentic, sanctions_hit and the rest β end like clean. A scenario shapes the result, not the verdict: it decides which check fails in the result Get a Verification Session returns, and the verdict is still the one you resolve.
Session creation answers 422 KYC_VALIDATION_ERROR, with the reason in detail, when a scenario cannot run on your tenant β it deviates on a check your account has not enabled, such as sanctions_hit without sanctions screening, or the sender has no first name, last name or birth date, which a dictated result needs. Nothing is created.
Sandbox only. Reserved numbers are armed per tenant. If one leaves the session waiting for a capture, ask your Inyo contact to enable simulation on your sandbox tenant.
Opening the session
Open it with the same request as in production; nothing in it changes. A session dictated by a reserved number is final at creation: the response still carries a widgetUrl, so your integration runs unchanged, but there is nothing to capture behind it.
Read the outcome from DocumentUpdatedEvents, whose id is the session id. It arrives shortly after the response, so your own record of the session exists first. Get a Verification Session is the fallback when a delivery is missed.
Resolving a Held KYC Session
Endpoint: POST /organizations/{tenant}/v2/senders/{personId}/kycSession/{sessionId}/resolve
Authentication: Agent-level
Availability: Sandbox/staging only β this endpoint does not exist in production.
Nobody reviews your test senders, so without this endpoint a held session never reaches a terminal state: no compliance level moves, and your DocumentUpdatedEvents stream stops after the PENDING.
This endpoint settles it. It runs the same code an Inyo reviewer's decision runs, so the document verdict, the sender's compliance level and the outbound event are the real ones β only the door is different.
curl --request POST \
--url https://{FQDN}/organizations/$TENANT/v2/senders/$PERSON_ID/kycSession/$SESSION_ID/resolve \
--header 'Content-Type: application/json' \
--header "x-api-key: $API_KEY" \
--header "x-agent-id: $AGENT_ID" \
--header "x-agent-api-key: $AGENT_KEY" \
--data '{
"outcome": "verified",
"reason": "Cleared for the integration walkthrough"
}'
| Field | Notes |
|---|---|
outcome | verified or rejected. Required. |
reason | Free text, up to 255 characters. Required β it is stored with the resolution. |
Answers 200 with the session in the same shape Get a Verification Session returns, carrying its new status and completedAt. A subscriber receives a second DocumentUpdatedEvents with the same id the earlier PENDING carried, so the two correlate.
Any held session qualifies, whether it was dictated by a simulated verification outcome or captured for real in the widget.
Refusals
Every refusal carries the session's actual status alongside error and message, because the code alone does not tell you what to do next β NOT_HELD covers a session still PENDING (come back later) and one already decided (stop and read the outcome).
{ "error": "NOT_HELD", "status": "VERIFIED", "message": "This session is VERIFIED, not held for review." }
| HTTP | error | Meaning |
|---|---|---|
404 | NOT_FOUND | No such session for this person, under this tenant |
409 | NOT_HELD | The session is not held for review β still pending, or already decided |
409 | MANAGED_TENANCY | Your tenancy is decided by the verification platform; the hold belongs to its own queue |
409 | DECIDED_ELSEWHERE | A later session already decided this document, so resolving this one would take no effect |
409 | ALREADY_RESOLVED | Something settled the session while your request was in flight |
422 | VALIDATION_ERROR | outcome missing or not one of the two values, or reason missing or too long |
The endpoint is not idempotent: calling it again after a successful resolve returns 409 NOT_HELD with the status it settled to, not 200. Branch on status rather than retrying blindly.
Resolving a Pending Document Review
Endpoint: POST /organizations/{tenant}/v2/senders/{personId}/documents/{documentId}/resolve
Authentication: Agent-level
Availability: Sandbox/staging only β this endpoint does not exist in production.
Uploads stay PENDING in sandbox: AI OCR is normally off and nobody reviews a test tenant's queue. The sender never advances a level and the DocumentUpdatedEvents stream stops after the first PENDING.
This settles it through the real path: same verification-status row, same webhook, same compliance outcome a reviewer's decision produces. verifiedBy carries a sandbox: prefix, so a simulated decision stays distinguishable.
Any of the person's uploads qualifies, not only source of funds. An identity document uploaded through the direct upload endpoints strands the same way and is read by the same gates.
curl --request POST \
--url https://{FQDN}/organizations/$TENANT/v2/senders/$PERSON_ID/documents/$DOCUMENT_ID/resolve \
--header 'Content-Type: application/json' \
--header "x-api-key: $API_KEY" \
--header "x-agent-id: $AGENT_ID" \
--header "x-agent-api-key: $AGENT_KEY" \
--data '{
"outcome": "verified",
"reason": "Cleared for the integration walkthrough"
}'
$DOCUMENT_ID is the id the upload response returned.
| Field | Notes |
|---|---|
outcome | verified or rejected. Required. |
reason | Free text, up to 255 characters. Required β it is stored with the resolution and shown to the reviewer. |
Answers 200 with the document's new status in the same shape Get Current Verification Status returns:
{
"status": "verified",
"reason": "Cleared for the integration walkthrough",
"verifiedBy": "sandbox:8f14e45f-ceea-467a-9f5e-9b2c4c9a1f77",
"createdAt": "2026-09-29T12:10:37+00:00"
}
A subscriber receives a DocumentUpdatedEvents carrying the document's id and the new verificationStatus.
Refusals
Every refusal carries the document's actual status alongside error and message, because the code alone does not tell you what to do next.
{ "error": "ALREADY_DECIDED", "status": "verified", "message": "This document is already verified; it is not waiting for a review." }
| HTTP | error | Meaning |
|---|---|---|
404 | NOT_FOUND | No such document for this person, under this tenant |
409 | ALREADY_DECIDED | The document is already verified or rejected β read the outcome rather than settling it again |
409 | NOT_PENDING | The document is in some other state this endpoint does not settle |
422 | VALIDATION_ERROR | outcome missing or not one of the two values, or reason missing or too long |
This endpoint settles a document that is waiting; it does not re-open one that was decided. It is not idempotent β calling it again after a successful resolve returns 409 ALREADY_DECIDED with the status it settled to, not 200. Branch on status rather than retrying blindly.
Transactions
Forcing Status Transitions
Endpoint: POST /organizations/{tenant}/fx/transactions/{transactionId}/events
Authentication: Agent-level
Availability: Sandbox/staging only β this endpoint does not exist in production.
curl --request POST \
--url https://{FQDN}/organizations/$TENANT/fx/transactions/$TRANSACTION_ID/events \
--header 'Content-Type: application/json' \
--header "x-api-key: $API_KEY" \
--header "x-agent-id: $AGENT_ID" \
--header "x-agent-api-key: $AGENT_KEY" \
--data '{
"event": "payment_authorized",
"message": "Optional description"
}'
Available Events
| Event | Valid from state | What it simulates |
|---|---|---|
payment_authorized | WaitingChallenge3ds | The gateway 3DS callback. Resumes the flow β dispatches payout integration. Use when the transaction is stuck waiting for 3DS. |
ach_settled | WaitingSettlement | The ACH settlement callback. Moves the transaction through PaymentSettled β PaymentCaptured and triggers payout confirmation. |
payment_captured | ReviewApproved | A card capture confirmation. Triggers payout confirmation. |
payout_paid | PayoutAccepted, PayoutReleased, WaitingPayout | The payout network confirming the beneficiary received funds. Moves the transaction to Paid β Completed. |
payout_void | PayoutAccepted, PayoutHold, PayoutReleased, WaitingPayout | The payout network voiding the transaction. Depending on tenant configuration, auto-reverses or parks at PendingReversalApproval. |
hold_released | PayoutHold | The payout network releasing a compliance hold. Resumes the flow. |
cancelled | Any cancellable state | Client-initiated cancellation (voids the payment and cancels the payout). |
refunded | Refundable states | Marks the transaction as refunded. |
If the transaction is not in a valid state for the event, the endpoint returns an error explaining the current state.
Typical Test Flow (Card + 3DS)
- Create the transaction β status
WaitingChallenge3ds,paymentStatus: ActionRequired - Force 3DS:
{ "event": "payment_authorized" } - Transaction auto-progresses β
PayoutAcceptedβManualReviewβReviewApprovedβPaymentCapturedβWaitingPayout - Force delivery:
{ "event": "payout_paid" } - Transaction reaches
Completed
Typical Test Flow (ACH)
- Create the transaction β progresses to
WaitingSettlement - Force settlement:
{ "event": "ach_settled" } - Transaction auto-progresses β
PaymentSettledβPaymentCapturedβWaitingPayout - Force delivery:
{ "event": "payout_paid" } - Transaction reaches
Completed
Payout-Network Test Scenarios
Sandbox forwards your sender's details to the payout network exactly as production does, and the network's own sandbox decides what happens next. Two sender values produce a deliberate outcome. Everything else is accepted and paid.
Compliance Hold
Sender name JULIO SOLANO triggers a compliance hold at the payout network:
{ "firstName": "JULIO", "lastName": "SOLANO", "...": "..." }
The transaction reaches PayoutHold after payout integration, with complianceStatus: Pending and payoutStatus: Processing. Release it with the hold_released force event (or wait for the network/backoffice to release it).
Expected flow:
... -> ProcessingPayout -> PayoutHold -> [hold released] -> PayoutReleased -> ...
Sender Rejected by the Network
A sender whose state and zipcode do not agree is refused by the payout network before the transfer is created. 96738 is a Hawaii zipcode, so sending it with a California address produces this:
{ "address": { "stateCode": "CA", "city": "Los Angeles", "zipcode": "96738" } }
The transaction does not become a rejection you can handle as one. It lands at Failed / Failed and is parked for manual intervention, because a data problem is treated as recoverable rather than as a refusal of the transfer:
complianceStatus | Failed |
payoutStatus | Failed |
| Message | Payout integration error. Please contact support if this persists. |
The message is deliberately generic β the payout network's own fault string is withheld from the API. If you hit this outside a test, the cause is almost always a sender address the network cannot resolve.
Payment Decline
Use a card token that the gateway sandbox rejects, or an expired token. The transaction reaches PaymentDeclined -> Cancelled.
3DS Decline
When redirected to the 3DS challenge page, choose "Decline" (where the sandbox offers it). The gateway reports the failure and the transaction moves to PaymentDeclined -> Cancelled.
Test Cards
Use sandbox test cards to simulate 3DS scenarios. The full list is in Payments Gateway test cards. Common ones:
| Card Number | Scheme | 3DS Behavior |
|---|---|---|
4462030000000000 | VISA | 3DS Challenge |
4035874000424977 | VISA | Frictionless (no challenge) |
5425230000004415 | Mastercard | 3DS Challenge |
4000000000000002 | VISA | Declined |
Tokenize these via the gateway SDK, then pass the token as
paymentMethod.tokenwhen creating a funding account.
Polling for Status
After creating a transaction, poll GET /organizations/{tenant}/fx/transactions/{transactionId} and watch:
| Field | What to expect |
|---|---|
complianceStatus | Pending β Approved (after payout integration) |
payoutStatus | Pending β Processing β Completed |
paymentStatus | ActionRequired β null (after 3DS completes) |
receipt | Regulatory disclosures (available from creation) |
For the full transition audit trail, use GET /fx/transactions/{transactionId}/status β see Transaction. In production, prefer webhooks over polling.
