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 answersRefund a UPI test payment and assert your code treats the refusal (ERR_3066 on POST /v1/refunds, after ERR_1012 if refunds are not enabled for your account) as "request this refund as a payout". See Methods without a refund API

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 you go live. 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.

Payment create and confirm accept one card shape: payment_method_data.psp_token, the token returned by the card fields hosted by the card processor enabled for your account. Raw card data sent to payment create or confirm is refused in every mode, sandbox included, with:

{ "error": { "type": "invalid_request", "code": "ERR_2029" } }

The message wording can change; branch on the code.

That refusal is deliberate: card details belong in the card fields hosted by the card processor, not in your requests. It also means a table of test card numbers would be unusable, so there isn't one. Card testing runs on a card rail enabled for your account; ask us when you need it.

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=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 and returns a next_action of the same shape the live crypto rail returns: image_data_url, raw_qr_data, pay_currency and pay_network. No coins, no testnet, no wallet needed.

Test mode states no crypto amount. pay_amount (and later paid_amount) are absent on simulated payments, and the payable string carries no amount, because the simulator has no exchange rate and will not invent one. Live payments always carry pay_amount. Both fields are optional in the response, so handle their absence; the hosted checkout simply shows the address and network without an amount line.

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). Test and live mode accept exactly the same pairs, the ones listed for the crypto method in the capability catalogue, and check them the same way:

  • Case does not matter: usdt on TRON is read as USDT on tron.
  • network is required when the asset is offered on more than one network (USDT is offered on six). There is no safe default: an address belongs to one chain, and funds sent on another chain cannot be recovered. An asset offered on exactly one network (for example POL on polygon) may leave network out, and that network is used.
  • An asset or network that is not offered is refused before any payment address is issued, with a message that names the field and lists what is accepted:
400 {"error":{"type":"invalid_request","code":"ERR_2002",
"message":"payment_method_data.crypto.network is required: USDT can be paid on more than one network, and funds sent on a different network from the address's cannot be recovered. Send one of: ethereum, tron, bnb_smart_chain, solana, polygon, ton."}}

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",
"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_action → succeededThe happy path
…05requires_customer_action → requires_merchant_action → succeeded on the next force_sync retrieveUnderpayment. On the live rail the network fee comes out of the amount the payer sends, so less arrives than was quoted (paid_amount below pay_amount); the rail then recalculates and settles it. Test mode reproduces the statuses, not the amounts
…15requires_customer_action → expired (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:

  • An address nobody pays ends expired. On the live rail that happens within a few hours of the address's timer running out, once nothing at all has arrived. Treat expired as "the payer did not pay in time", not as a refusal. A transfer can still land after that: the payment then moves to requires_merchant_action, because money arrived that nobody was waiting for, and from there to succeeded or to a refund, depending on how the late payment is resolved. Handle all three statuses, and do not treat expired as final for a crypto payment.
  • paid_amount can be less than pay_amount on a succeeded live 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 (pay_amount is absent in test mode).

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.