Skip to main content

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:

SecretPublishable
Testrail_full_test_…rail_open_test_…
Liverail_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) and rail_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. 50000 means 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 same Idempotency-Key and body returning the same payment_id all behave exactly as they do with live keys. What you validate in test is what runs in production.

The workflow

  1. Integrate against your test secret key.
  2. 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.
  3. Confirm you branch correctly on the payment status (succeeded, failed, requires_customer_action) rather than on the customer returning to your return_url.
  4. 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:

ScenarioHow to drive it in test mode
Create / confirm / retrieve happy pathNormal calls with the test secret key
Idempotent retryRepeat a create with the same Idempotency-Key and body; assert you get the same payment_id back
Idempotency misuseRepeat 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 errorsOmit 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 errorsCapture a payment that is not requires_capture; assert you handle ERR_3004
Mode mismatchRetrieve a test payment with a live key; assert ERR_3051 surfaces as a config alert
Webhook verificationReal signed deliveries to your test endpoint; also assert a tampered body is rejected with 400
Webhook duplicatesRe-drive a delivery from the dashboard (Developers → Webhooks); assert your handler fulfils only once
Refund pathCreate 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.)

Read the decline, don't just check for failure

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/failure or pending forces that outcome (for example success@upi).
  • UPI and cards: the amount's last two minor units decide — ending 00 succeeds, 51 fails, 99 stays pending. Anything else succeeds. This is the fallback when no magic identifier is supplied.
  • Crypto has its own table, below. …51 still fails; …99 does not mean pending there.

A payment left requires_customer_action by a pending trigger resolves when the payment is retrieved with force_sync=trueGET /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 unitsWhat happensWhy it is in here
anything else (e.g. …00)requires_customer_actionsucceeded for the full quoteThe happy path
…05requires_customer_actionrequires_merchant_action with paid_amount below pay_amountsucceeded on the next force_sync retrieveUnderpayment. The network fee comes out of the amount the payer sends, so less arrives than was quoted; the rail then recalculates and settles it
…15requires_customer_actionfailed (the address expired unpaid)A crypto address is payable for a limited window and an on-chain transfer can miss it
…51failed immediately, no address issuedThe 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_amount can be less than pay_amount on a succeeded payment. pay_amount is the quote the payer was asked for; paid_amount is 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_data is often a bare address, not a URI. Only some chains have a payment-URI standard. On the chains that do not, raw_qr_data is the destination address by itself — do not put it in an href, 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 use pay_amount / pay_currency / pay_network to 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

The presented amount is up to ₹2 BELOW your order amount. This is correct.

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:

OrderPresentedReduction
₹2000.00₹1999.973 paise
₹300.00₹299.1486 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.

Local-rail completion: an honesty note

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 processing past your fulfilment window: your job is to wait for the webhook (or reconcile by retrieve), never to time out into fulfilment.
  • A failed payment: checkout offers a new payment with a new Idempotency-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.