Skip to main content

Quickstart: your first 10 minutes

This guide takes you from zero to a payment and a webhook over the REST API. It uses UPI (India) as the concrete example, but the flow is the same for every method: one API, one base URL for test and live, and the methods available to a customer are the ones enabled for your account. You never hard-code a rail, and there is no TensorRail package to install: every step is an HTTPS request.

Prefer to click around first? The full API is downloadable as an OpenAPI spec at /openapi/tensorrail-merchant.json and imports straight into Postman or Insomnia.

You need: a TensorRail account with dashboard access, your test keys, and (for step 4) an HTTPS URL that reaches your machine (a tunnel to localhost is fine during development).

1. Get your keys​

From the dashboard, go to Developers → API Keys and copy your test keys:

  • Secret key rail_full_test_… for server-to-server calls.
  • Publishable key rail_open_test_… for anything in the browser.

Build against test keys first. The key selects the environment; there is one base URL, https://api.tensorrail.com. See Authentication.

2. Create a payment​

Send an amount (smallest currency unit) and a currency. Include an Idempotency-Key so a network retry cannot create a duplicate: retrying with the same key replays the original request and returns the original payment, whatever the retry's body says. Never reuse a key with a different body: there is no mismatch rejection; the differing body is silently ignored. (The header is honored only when the body does not supply its own payment_id.)

curl -X POST https://api.tensorrail.com/payments \
-H "api-key: rail_full_test_xxx" \
-H "Idempotency-Key: 5f0c9e2a-1b7d-4d3e-9a1c-6b2f8e4a1c33" \
-H "Content-Type: application/json" \
-d '{
"amount": 50000,
"currency": "INR",
"customer": { "email": "buyer@example.com" },
"description": "Order #12345",
"metadata": { "order_id": "12345" },
"return_url": "https://yourshop.com/checkout/complete"
}'

Amounts are in the smallest unit (paise for INR): 50000 = INR 500.00. You get back the payment object; the two fields you must keep are payment_id (store it against your order) and client_secret (hand it to the browser):

{
"payment_id": "pay_N5cPeGw6uS2QIMjnsjVF",
"status": "requires_payment_method",
"amount": 50000,
"currency": "INR",
"client_secret": "pay_N5cPeGw6uS2QIMjnsjVF_secret_…",
"description": "Order #12345",
"metadata": { "order_id": "12345" },
"return_url": "https://yourshop.com/checkout/complete"
}

If this call fails, the body is an error object; the usual first-run causes:

You seeCause
ERR_1002Missing or wrong api-key header value
ERR_2001A required field (for example amount) missing; the field is named in message
ERR_2002currency is not a well-formed ISO 4217 code. A valid code you have no corridor for is accepted here and fails later at confirm with ERR_4088; see Corridors and coverage
ERR_3051Test/live mode mismatch: check which key you loaded

3. Present and confirm​

There are three ways to put the payment in front of the customer, and this is the only decision that matters: hosted checkout, a payment link, or confirm it yourself over the API. Hosted checkout and confirming it yourself use the payment you just created; a payment link is created on its own and lives 24 hours. See Choose your integration.

Fastest: the hosted page. Redirect the customer to pay.tensorrail.com with the payment's identifiers and your publishable key; TensorRail renders every enabled method and returns the customer to your return_url:

https://pay.tensorrail.com/?mode=link&payment_id=pay_N5c…&client_secret=pay_N5c…_secret_…&pk=rail_open_test_xxx&amount=50000&currency=INR

That URL is only good while the payment is: 15 minutes by default. Redirecting a browser now, that is fine. Emailing the link, it is not: pass session_expiry (seconds) on the create call to widen it. See Hosted checkout.

No checkout at all: a payment link. Create a link with POST /v1/payment-links/prepare, or from the dashboard without code, and send it by email, chat or invoice. It creates its own payment, so it does not use the one from step 2. See Payment links.

Full control: confirm yourself. Render the enabled methods and confirm the customer's choice from your own checkout. The full field reference is in Accept a payment:

curl -X POST https://api.tensorrail.com/payments/pay_N5c…/confirm \
-H "api-key: rail_full_test_xxx" \
-H "Content-Type: application/json" \
-d '{
"payment_method": "upi",
"payment_method_type": "upi_intent",
"payment_method_data": { "upi": { "upi_intent": {} } },
"customer": { "email": "buyer@example.com" },
"billing": { "email": "buyer@example.com" }
}'

The response comes back with status: "requires_customer_action" and a generic next_action: for UPI, where to send the customer so they can approve in their bank app. Forward the customer to next_action generically, without inspecting what kind of action it is; that keeps your checkout working as new methods are added. Always send payment_method_type explicitly; omitting it can block later refunds.

4. Receive a webhook​

The customer returning to your return_url is not proof of payment; local rails complete asynchronously. Treat a payment as paid only on the payment_succeeded webhook (or a retrieve returning status: "succeeded").

Set your webhook URL and copy your signing secret in the dashboard under Developers → Webhooks, then run a minimal handler:

const express = require("express");
const crypto = require("crypto");

const app = express();
const SECRET = process.env.TENSORRAIL_WEBHOOK_SECRET;

app.post("/webhooks/tensorrail", express.raw({ type: "*/*" }), (req, res) => {
const header = req.get("TensorRail-Signature") || "";
const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
const expected = crypto
.createHmac("sha512", SECRET)
.update(`${parts.t}.${req.body}`)
.digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
if (!fresh || expected.length !== (parts.v1 || "").length ||
!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))) {
return res.status(400).end();
}

const event = JSON.parse(req.body);
if (event.content.object.status === "succeeded") {
// fulfil the order for event.content.object.metadata.order_id, idempotently
}
res.status(200).end();
});

app.listen(4242);

The event you are waiting for:

{
"event_id": "evt_9f2c…",
"event_type": "payment_succeeded",
"timestamp": "2026-07-22T09:03:11Z",
"content": {
"type": "payment_details",
"object": {
"payment_id": "pay_N5cPeGw6uS2QIMjnsjVF",
"status": "succeeded",
"amount": 50000,
"currency": "INR",
"metadata": { "order_id": "12345" }
}
}
}

The full contract (event types, de-duplication, retries) is in Handle webhooks. As a fallback or for reconciliation, you can always retrieve the current state directly:

curl https://api.tensorrail.com/payments/pay_N5c… \
-H "api-key: rail_full_test_xxx"

The collected value is now on your balance. Read it with GET /v1/balances; see Balances.

What you just built works for every method​

You wrote a create call, a way to present options, and a webhook handler. That same code takes mobile money, bank transfer, and every other rail enabled for you, with no changes. It is also already production-shaped: idempotent creates, signature-verified webhooks, and fulfilment keyed on status, which is exactly what Go live checks for.

Next steps​

You have a payment and a verified webhook. From here:

  • Choose your integration: decide between hosted checkout, payment links, and the direct API now that you have seen the create call.
  • Accept a payment: the create and confirm fields in full, plus declines, retries, and manual capture.
  • Handle webhooks: the full event contract, ordering, and signature verification behind the handler you just wrote.

TensorRail, Limassol, Cyprus.