Sandbox Testing
The sandbox lets you drive a transaction through its entire lifecycle without real money movement. Three tools make this possible: 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, and a resolve endpoint that settles a KYC verification session held for review, which in sandbox nobody would otherwise pick up.
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
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.
In sandbox, every verification 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 review path is the one you exercise by default. The exceptions are sessions that end without a verdict at all β FAILED, ERRORED, EXPIRED β which are terminal on their own and never reach review.
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.
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.
Zipcode Validation
Before any of the above, a sender's zipcode is validated 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.
Reserved Document Numbers
Sender creation format-checks every declared document against the Inyo KYC service, so an invented number can fail the request before anything is persisted. Sandbox accepts reserved document numbers that return a chosen valid verdict, covering driver's licences, passports and identity cards.
The same catalog carries numbers that dictate a whole verification session outcome, which is what you want when testing the KYC flow rather than the number format.
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.
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.
