Inyo

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

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

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"
}'
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.


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.


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:

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.


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 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.