Quickstart: your first 10 minutes
This guide takes you from zero to a payment and a webhook. It uses UPI (India) as the concrete example, but the flow is the same for every method: you integrate once, and the methods available to a customer are the ones enabled for your account. You never hard-code a rail.
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
- Node
- Python
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"
}'
const response = await fetch("https://api.tensorrail.com/payments", {
method: "POST",
headers: {
"api-key": "rail_full_test_xxx",
"Idempotency-Key": "5f0c9e2a-1b7d-4d3e-9a1c-6b2f8e4a1c33",
"Content-Type": "application/json",
},
body: JSON.stringify({
amount: 50000,
currency: "INR",
customer: { email: "buyer@example.com" },
description: "Order #12345",
metadata: { order_id: "12345" },
return_url: "https://yourshop.com/checkout/complete",
}),
});
const payment = await response.json();
import requests
response = requests.post(
"https://api.tensorrail.com/payments",
headers={
"api-key": "rail_full_test_xxx",
"Idempotency-Key": "5f0c9e2a-1b7d-4d3e-9a1c-6b2f8e4a1c33",
},
json={
"amount": 50000,
"currency": "INR",
"customer": {"email": "buyer@example.com"},
"description": "Order #12345",
"metadata": {"order_id": "12345"},
"return_url": "https://yourshop.com/checkout/complete",
},
)
payment = response.json()
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 see | Cause |
|---|---|
ERR_1002 | Missing or wrong api-key header value |
ERR_2001 | A required field (for example amount) missing; the field is named in message |
ERR_2002 | currency 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_3051 | Test/live mode mismatch: check which key you loaded |
3. Present and confirm
You have two choices, and this is the only decision that matters. 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¤cy=INR
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
- Node
- Python
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" }
}'
const paymentId = "pay_N5c…"; // from step 2
const response = await fetch(
`https://api.tensorrail.com/payments/${paymentId}/confirm`,
{
method: "POST",
headers: {
"api-key": "rail_full_test_xxx",
"Content-Type": "application/json",
},
body: JSON.stringify({
payment_method: "upi",
payment_method_type: "upi_intent",
payment_method_data: {
upi: { upi_intent: {} },
},
customer: { email: "buyer@example.com" },
billing: { email: "buyer@example.com" },
}),
}
);
const payment = await response.json();
import requests
payment_id = "pay_N5c…" # from step 2
response = requests.post(
f"https://api.tensorrail.com/payments/{payment_id}/confirm",
headers={"api-key": "rail_full_test_xxx"},
json={
"payment_method": "upi",
"payment_method_type": "upi_intent",
"payment_method_data": {
"upi": {"upi_intent": {}}
},
"customer": {"email": "buyer@example.com"},
"billing": {"email": "buyer@example.com"},
},
)
payment = response.json()
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:
- Node (Express)
- Python (Flask)
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);
import hashlib
import hmac
import json
import os
import time
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["TENSORRAIL_WEBHOOK_SECRET"]
@app.post("/webhooks/tensorrail")
def tensorrail_webhook():
raw = request.get_data() # raw bytes, before any parsing
header = request.headers.get("TensorRail-Signature", "")
parts = dict(kv.split("=", 1) for kv in header.split(","))
expected = hmac.new(
SECRET.encode(), f"{parts['t']}.".encode() + raw, hashlib.sha512
).hexdigest()
fresh = abs(time.time() - int(parts["t"])) < 300
if not (fresh and hmac.compare_digest(expected, parts["v1"])):
return "", 400
event = json.loads(raw)
if event["content"]["object"]["status"] == "succeeded":
pass # fulfil the order for event["content"]["object"]["metadata"]["order_id"], idempotently
return "", 200
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.