Inyo

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:

ZipcodeCountryResult
99999, 00000, AAAAAmost422 - all-same-character values are refused as placeholder data
ABCUS422 - does not match the country's postal format
12345, 12345-6789USaccepted

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:

  1. Declare a document on the sender, carrying a reserved number.
  2. The number decides the ending.
  3. Open the session and read its outcome from DocumentUpdatedEvents.
  4. 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:

NumberWhat the session doesWhat DocumentUpdatedEvents delivers
clean β€” 99900101, 99900201, 999003001Completes at creation with a full resultPENDING, then VERIFIED or REJECTED once you resolve it
capture_exhausted β€” 99900108, 99900208, 999003008Ends at creation as a sender who ran out of capture attemptsFAILED
link_expired β€” 99900113, 99900213, 999003013Is created already expired; its link shows the expired-link messageEXPIRED
Any other numberWaits for a real capture in the widget, analysed as in productionWhatever 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"
}'
FieldNotes
outcomeverified or rejected. Required.
reasonFree 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." }
HTTPerrorMeaning
404NOT_FOUNDNo such session for this person, under this tenant
409NOT_HELDThe session is not held for review β€” still pending, or already decided
409MANAGED_TENANCYYour tenancy is decided by the verification platform; the hold belongs to its own queue
409DECIDED_ELSEWHEREA later session already decided this document, so resolving this one would take no effect
409ALREADY_RESOLVEDSomething settled the session while your request was in flight
422VALIDATION_ERRORoutcome 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.

FieldNotes
outcomeverified or rejected. Required.
reasonFree 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." }
HTTPerrorMeaning
404NOT_FOUNDNo such document for this person, under this tenant
409ALREADY_DECIDEDThe document is already verified or rejected β€” read the outcome rather than settling it again
409NOT_PENDINGThe document is in some other state this endpoint does not settle
422VALIDATION_ERRORoutcome 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

EventValid from stateWhat it simulates
payment_authorizedWaitingChallenge3dsThe gateway 3DS callback. Resumes the flow β€” dispatches payout integration. Use when the transaction is stuck waiting for 3DS.
ach_settledWaitingSettlementThe ACH settlement callback. Moves the transaction through PaymentSettled β†’ PaymentCaptured and triggers payout confirmation.
payment_capturedReviewApprovedA card capture confirmation. Triggers payout confirmation.
payout_paidPayoutAccepted, PayoutReleased, WaitingPayoutThe payout network confirming the beneficiary received funds. Moves the transaction to Paid β†’ Completed.
payout_voidPayoutAccepted, PayoutHold, PayoutReleased, WaitingPayoutThe payout network voiding the transaction. Depending on tenant configuration, auto-reverses or parks at PendingReversalApproval.
hold_releasedPayoutHoldThe payout network releasing a compliance hold. Resumes the flow.
cancelledAny cancellable stateClient-initiated cancellation (voids the payment and cancels the payout).
refundedRefundable statesMarks 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)

  1. Create the transaction β†’ status WaitingChallenge3ds, paymentStatus: ActionRequired
  2. Force 3DS: { "event": "payment_authorized" }
  3. Transaction auto-progresses β†’ PayoutAccepted β†’ ManualReview β†’ ReviewApproved β†’ PaymentCaptured β†’ WaitingPayout
  4. Force delivery: { "event": "payout_paid" }
  5. Transaction reaches Completed

Typical Test Flow (ACH)

  1. Create the transaction β†’ progresses to WaitingSettlement
  2. Force settlement: { "event": "ach_settled" }
  3. Transaction auto-progresses β†’ PaymentSettled β†’ PaymentCaptured β†’ WaitingPayout
  4. Force delivery: { "event": "payout_paid" }
  5. 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:

complianceStatusFailed
payoutStatusFailed
MessagePayout 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 NumberScheme3DS Behavior
4462030000000000VISA3DS Challenge
4035874000424977VISAFrictionless (no challenge)
5425230000004415Mastercard3DS Challenge
4000000000000002VISADeclined

Tokenize these via the gateway SDK, then pass the token as paymentMethod.token when creating a funding account.


Polling for Status

After creating a transaction, poll GET /organizations/{tenant}/fx/transactions/{transactionId} and watch:

FieldWhat to expect
complianceStatusPending β†’ Approved (after payout integration)
payoutStatusPending β†’ Processing β†’ Completed
paymentStatusActionRequired β†’ null (after 3DS completes)
receiptRegulatory disclosures (available from creation)

For the full transition audit trail, use GET /fx/transactions/{transactionId}/status β€” see Transaction. In production, prefer webhooks over polling.