Testing and sandbox
Build and validate your integration with test-mode keys before you touch real money. Test and live data never mix.
Test vs live keys
Every account has two sets of keys, in the dashboard under Developers → API Keys:
| Secret | Publishable | |
|---|---|---|
| Test | rail_full_test_… | rail_open_test_… |
| Live | rail_full_live_… | rail_open_live_… |
The key you use decides the environment; there is one base URL,
https://api.tensorrail.com, for both. Payments you create with a test key never appear in
your live balance, reporting, or settlements, and a key used against the wrong mode's data is
rejected with ERR_3051. See Authentication.
Test-mode facts
The details worth pinning down before you write a line of code:
- The mode lives in the key.
rail_full_test_…(secret) andrail_open_test_…(publishable) operate entirely on sandbox data. There is no separate test host and no test flag on the request; loading a test key is what puts you in test mode. - Test and live data do not mix. Test payments and refunds are scoped to the key that asks:
a live key returns only live data, and a test key returns only test data. Your balances,
ledger, and settlements each record which environment they belong to, so test activity cannot
land in a live balance. Present a key whose mode does not match the resource and the call is
rejected with
ERR_3051(mode mismatch). - Test-mode payments move no real money. A test collection never reaches a real processor. Rails that settle real money are refused on test keys rather than quietly used; instead, test keys route UPI to the TensorRail sandbox simulator, which is why the quickstart's UPI example works in test mode. A test payment cannot create a real collection request. (Cards are different — see Test cards below.)
- Amounts are in the smallest currency unit.
50000means INR 500.00 (paise), the same as in live mode. There is no separate test amount format. - The core flow is identical to live.
POST /payments(create) →GET /payments/{id}(retrieve) → an idempotent retry with the sameIdempotency-Keyand body returning the samepayment_idall behave exactly as they do with live keys. What you validate in test is what runs in production.
The workflow
- Integrate against your test secret key.
- Drive the full path you will use in production: create then present then confirm then webhook. Point your webhook endpoint at a test URL and verify you handle and verify signatures on real deliveries.
- Confirm you branch correctly on the payment
status(succeeded,failed,requires_customer_action) rather than on the customer returning to yourreturn_url. - Switch to live keys only when the whole path works end to end.
What you can fully exercise with test keys
Everything in your own code path is testable in isolation, against real API behavior:
| Scenario | How to drive it in test mode |
|---|---|
| Create / confirm / retrieve happy path | Normal calls with the test secret key |
| Idempotent retry | Repeat a create with the same Idempotency-Key and body; assert you get the same payment_id back |
| Idempotency misuse | Repeat with the same key and a different body; assert you get the original payment back unchanged. POST /payments does not compare bodies, so this is silent — your test is that your code notices the returned amount is not the one it sent |
| Validation errors | Omit amount (ERR_2001 with field_name); send a malformed currency like ZZZ (ERR_2002). Note a valid but non-enabled currency is accepted at create — see Corridors and coverage |
| State-machine errors | Capture a payment that is not requires_capture; assert you handle ERR_3004 |
| Mode mismatch | Retrieve a test payment with a live key; assert ERR_3051 surfaces as a config alert |
| Webhook verification | Real signed deliveries to your test endpoint; also assert a tampered body is rejected with 400 |
| Webhook duplicates | Re-drive a delivery from the dashboard (Developers → Webhooks); assert your handler fulfils only once |
| Refund path | Create and retrieve refunds, including a partial and an over-amount attempt (ERR_3005) |
The dashboard's delivery log and re-drive button are your webhook test harness: every attempt shows what your server answered, and re-driving is exactly the duplicate delivery your handler must tolerate in production.
Test cards
The TensorRail Test Simulator is enabled on every account — there is nothing to request and nothing to switch on. It is a no-network simulator: nothing leaves our platform, no real processor is involved, and no money moves. It gives you deterministic outcomes on the rails it drives — UPI in particular, via the VPA and amount conventions below — so you can build and assert against every branch of your integration before a real rail is connected. It also drives crypto, on the same test connector, with no coins and no testnet.
It stays available after you go live. Test mode is an environment, not a stage you graduate out of, so you keep a way to exercise your integration for as long as you have an account.
Cards are the exception. Card testing runs through tokenized fields rather than raw card numbers, in every mode, so there is no card input a test key alone can supply — the simulator's card branches are not reachable through the public API, and there is no list of test card numbers to hand you.
The API accepts exactly one card shape — payment_method_data.psp_token, a token minted by the
processor's secure fields. Raw card numbers are refused in every mode, sandbox included, with:
{ "error": { "type": "invalid_request", "code": "ERR_2029",
"message": "PspDirectPassthrough mode requires a PSP-direct token; raw card intake is not accepted (send PaymentMethodData::PspToken)" } }
That refusal is deliberate: no card number reaches TensorRail. It also means a table of test card numbers would be unusable, so there isn't one. To exercise cards you need a processor account of your own and its secure-fields token.
Test against a corridor enabled for you. The quickstart drives a UPI
(India) collection end to end in test mode as a worked example; the corridors and methods
enabled on your own account are in your dashboard. If the corridor you want to exercise is not
among them, ask your account manager. (The unauthenticated
GET /v1/capabilities catalogue tells you what
the platform supports, not what is switched on for you.)
Every declined payment carries a distinct error_code, so branch on the
code rather than on "not succeeded". A UPI decline driven by a fail VPA returns ERR_4094
with status: "failed"; a pending VPA returns status: "requires_customer_action", which is
not a failure at all and must not be treated as one. The full list is in the
error catalog.
Simulating other payment methods
The same simulator drives non-card methods. The mechanism is the same everywhere — the
amount's last two minor units decide the outcome, and a method-specific identifier (a UPI VPA)
overrides it where one exists — but the table of values is per rail, because the rails do
not have the same outcomes. Only …51 means the same thing on all of them.
- UPI collect: a VPA starting
success,fail/failureorpendingforces that outcome (for examplesuccess@upi). - UPI and cards: the amount's last two minor units decide — ending
00succeeds,51fails,99stays pending. Anything else succeeds. This is the fallback when no magic identifier is supplied. - Crypto has its own table, below.
…51still fails;…99does not mean pending there.
A payment left requires_customer_action by a pending trigger resolves when the payment is
retrieved with force_sync=true — GET /payments/{payment_id}?force_sync=true. A plain
retrieve returns our stored view and does not ask the rail again, so it will keep returning
requires_customer_action for as long as you call it.
Crypto
Send payment_method: "crypto" and payment_method_type: "crypto_currency" with a test key
and the simulator mints a payment address, quotes an amount and returns a next_action of the
same shape the live crypto rail returns — image_data_url, raw_qr_data, pay_currency,
pay_amount and pay_network. No coins, no testnet, no wallet needed.
payment_method_type is required. Without it the create is rejected before it reaches a rail:
400 {"error":{"type":"invalid_request","code":"ERR_2001",
"message":"Missing required param: payment_method_type",
"field_name":"payment_method_type"}}
payment_method_data.crypto takes pay_currency (the asset, e.g. USDT) and network (the
chain, e.g. tron). Omit them and you get USDT on tron.
A complete request:
curl -X POST https://api.tensorrail.com/payments \
-H "api-key: $TENSORRAIL_TEST_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": 5000,
"currency": "EUR",
"confirm": true,
"capture_method": "automatic",
"payment_method": "crypto",
"payment_method_type": "crypto_currency",
"payment_method_data": { "crypto": { "pay_currency": "USDT", "network": "tron" } }
}'
which returns:
{
"payment_id": "pay_...",
"status": "requires_customer_action",
"next_action": {
"type": "qr_code_information",
"pay_currency": "USDT",
"pay_network": "tron",
"pay_amount": "50.00",
"raw_qr_data": "...",
"image_data_url": "data:image/png;base64,..."
}
}
A crypto payment is always requires_customer_action when you create it — an address has
been issued and nobody has sent anything to it yet. It resolves only on a retrieve with
force_sync=true:
GET /payments/{payment_id}?force_sync=true
Nothing else moves it. A plain retrieve returns our stored view without asking the rail, and no
webhook arrives on its own: a real crypto payment advances when the chain is polled, and the
simulator models that faithfully rather than firing a timer. Each force_sync retrieve is one
step of the lifecycle, so the underpayment case below needs two. (A force_sync retrieve
that advances the payment emits the matching outgoing webhook to your endpoint as usual — the
point is only that nothing emits one until you ask.)
The amount's last two minor units choose which of the three real lifecycles you get. Note this
is a crypto-specific table: …99 is a pending trigger on UPI and cards, but on crypto it is an
ordinary in-full payment, because a crypto payment is already pending at create.
| Last two minor units | What happens | Why it is in here |
|---|---|---|
anything else (e.g. …00) | requires_customer_action → succeeded for the full quote | The happy path |
…05 | requires_customer_action → requires_merchant_action with paid_amount below pay_amount → succeeded on the next force_sync retrieve | Underpayment. The network fee comes out of the amount the payer sends, so less arrives than was quoted; the rail then recalculates and settles it |
…15 | requires_customer_action → failed (the address expired unpaid) | A crypto address is payable for a limited window and an on-chain transfer can miss it |
…51 | failed immediately, no address issued | The shared hard-failure rule |
Two things in that table are worth building for explicitly, because they are the ones that surprise people in production:
paid_amountcan be less thanpay_amounton asucceededpayment.pay_amountis the quote the payer was asked for;paid_amountis what the chain delivered. On an underpayment the rail recalculates and settles, so you are credited the full order value while the on-chain figures disagree. Record both. If you reconcile against the quote you will find breaks that are not breaks.raw_qr_datais often a bare address, not a URI. Only some chains have a payment-URI standard. On the chains that do not,raw_qr_datais the destination address by itself — do not put it in anhref, because a bare address is a relative URL and will 404 on your own site. Render it as copyable text next to the QR, and usepay_amount/pay_currency/pay_networkto tell the payer what to send and where.
Simulated addresses are deterministic per payment and are visibly fake (the Tron-shaped ones
contain Simu1ator). They hold nothing and are not on any chain.
The UPI amount is the matching key
Create a UPI intent payment for ₹300.00 in test mode and the upi:// URI in your
next_action will ask for something like ₹298.29.
That is not a rounding bug and it is not the simulator being loose. The live UPI rail collects
into a pooled payee address that cannot carry an order reference, so the amount itself is the
matching key: each order is presented below its face value, and that unique figure is what
identifies the deposit. The payer is debited the lower figure; your order is credited in
full, and the payment's amount never changes.
The reduction is bigger than "a few paise", which is the part that catches people out. Reductions we have recorded on the live rail:
| Order | Presented | Reduction |
|---|---|---|
| ₹2000.00 | ₹1999.97 | 3 paise |
| ₹300.00 | ₹299.14 | 86 paise |
| ₹300.00 | ₹298.96 | ₹1.04 |
| ₹300.00 | ₹298.29 | ₹1.71 |
The last three are the same order, on three attempts — the figure is per attempt, not per order, and it crosses the rupee boundary. A tolerance of "within ₹1" therefore passes in testing and fails in production. The simulator applies a reduction of up to ₹1.99 for exactly that reason: it is wide enough that a too-tight tolerance breaks here first.
The simulator reproduces this deterministically — the same payment always presents the same
figure — so that an integration which handles sandbox handles production. Do not assert that
the amount inside next_action.sdk_uri equals your order amount, and do not tolerance-check it
at all. Branch on the payment status and the payment's own amount, never on the figure shown
to the payer. And if you render the amount for a payer doing a manual transfer, render the
presented figure exactly — a payer who "corrects" ₹298.29 back to ₹300.00 makes a payment
that cannot be matched to any order.
Completing a test collection
Test keys let you build and exercise the whole integration: create, present, confirm,
next_action, and webhook handling, safely and in isolated data. What test keys do not do
is let you approve a payment on the local rail from your desk.
The TensorRail Test Simulator (above) gives you deterministic completed, failed and pending outcomes for cards, UPI and crypto, including the underpaid and expired crypto lifecycles. That is what you build and assert your integration against. What a simulator cannot prove is the behaviour of a specific local network with your specific corridor enabled — local rails such as mobile money, UPI and bank transfer rarely publish a simulator of their own. So your final pre-launch check remains one small real collection on an enabled corridor: simulate everything while you build, then validate completion once for real before you send volume.
Your onboarding contact confirms the exact test credentials available for your enabled corridors.
Test the unhappy paths too
Make sure your integration handles a failed and a still-pending collection, not just success; local rails time out and get declined, and your reconciliation depends on handling those cleanly. In particular:
- A payment that stays
processingpast your fulfilment window: your job is to wait for the webhook (or reconcile by retrieve), never to time out into fulfilment. - A
failedpayment: checkout offers a new payment with a newIdempotency-Key, and nothing is fulfilled. - A late or duplicate webhook: fulfilment happens exactly once, and a stale non-terminal event can never un-fulfil an order. See delivery semantics.
Drive your handling off the payment status field, which your webhook handler should branch
on regardless of the rail.
Then go live
When the whole path works on test keys, move to Go live: switch to live keys, re-point and re-verify your webhook URL, and watch one small real collection land end to end before you send volume.
Next steps
Your integration is validated in test mode. The final stretch:
- Go live: switch to live keys and watch your first real collection.
- Handle webhooks: confirm your handler tolerates duplicates and verifies every signature.
- Error intelligence: make sure you branch on the right codes for each failure.
TensorRail, Limassol, Cyprus.