Direct API
The direct API is the integration path where you render your own checkout UI and drive the
payment yourself: create the payment, present the enabled methods, confirm the customer's
choice, handle whatever next_action the rail requires, and confirm the outcome by webhook.
It gives you the most control over the payment experience, and in return you own the most
responsibility.
"Direct API" is one of three ways to integrate onto the one TensorRail platform, alongside hosted checkout and payment links. It is the same account, the same balance, the same webhooks, the same error catalog, and the same payment object as the other paths. The only difference is that here you draw the payment screen instead of TensorRail. It is not a different product, a different host, or a different pricing model.
When to use it
- The checkout is part of your product's core UX and you want to control every pixel.
- You need to embed payment collection inside an existing flow (an app, a portal, a POS).
- You are prepared to handle
next_action, decline UX, and dynamic method rendering yourself.
If you want the fastest launch with no UI to maintain, use hosted checkout. You can start hosted and move to the direct API later without changing your create call, webhooks, or reconciliation, because those are identical across paths.
What you own on this path
The hosted page carries three things for you that you take on with the direct API:
next_actionhandling. Forward the customer to whatever the confirm response asks for (a redirect, an in-app approval, reference details), treating its shape as opaque.- Decline UX. A
failedpayment is terminal for that payment; offer the customer a fresh attempt (a new payment, a newIdempotency-Key). - Dynamic method rendering. Present the methods enabled for your account rather than hard-coding them, so a newly enabled method appears without a release.
The flow
- Create the payment server-side with your secret key.
- Present the enabled methods in your own UI.
- Confirm the customer's chosen method.
- Handle
next_actionif the rail needs a customer action. - Confirm the outcome by webhook.
Step 1: Create
POST /payments. amount and currency are required. Full create fields, idempotency, and
the response are documented in Accept a payment; the
short version:
- curl
- Node
- Python
curl -X POST https://api.tensorrail.com/payments \
-H "api-key: rail_full_test_xxx" \
-H "Idempotency-Key: 3d9a1c22-4e77-4b0a-8f21-2c6d9b0e5a44" \
-H "Content-Type: application/json" \
-d '{
"amount": 50000,
"currency": "INR",
"return_url": "https://yourshop.com/checkout/complete",
"metadata": { "order_id": "12345" }
}'
const r = await fetch("https://api.tensorrail.com/payments", {
method: "POST",
headers: {
"api-key": process.env.TENSORRAIL_SECRET_KEY,
"Idempotency-Key": "3d9a1c22-4e77-4b0a-8f21-2c6d9b0e5a44",
"Content-Type": "application/json",
},
body: JSON.stringify({
amount: 50000,
currency: "INR",
return_url: "https://yourshop.com/checkout/complete",
metadata: { order_id: "12345" },
}),
});
const payment = await r.json();
import requests
r = requests.post(
"https://api.tensorrail.com/payments",
headers={
"api-key": "rail_full_test_xxx",
"Idempotency-Key": "3d9a1c22-4e77-4b0a-8f21-2c6d9b0e5a44",
},
json={
"amount": 50000,
"currency": "INR",
"return_url": "https://yourshop.com/checkout/complete",
"metadata": {"order_id": "12345"},
},
)
payment = r.json()
Persist payment_id, and pass client_secret to the browser only if you confirm client-side
(below).
Step 2: Confirm
POST /payments/{payment_id}/confirm with the customer's chosen method. Send
payment_method_type explicitly; omitting it can block a later refund. Full confirm fields
are 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" },
"return_url": "https://yourshop.com/checkout/complete"
}'
const r = await fetch("https://api.tensorrail.com/payments/pay_N5c…/confirm", {
method: "POST",
headers: {
"api-key": process.env.TENSORRAIL_SECRET_KEY,
"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" },
return_url: "https://yourshop.com/checkout/complete",
}),
});
const confirmed = await r.json();
import requests
r = requests.post(
"https://api.tensorrail.com/payments/pay_N5c…/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"},
"return_url": "https://yourshop.com/checkout/complete",
},
)
confirmed = r.json()
You can also confirm from the browser with your publishable key plus the payment's
client_secret, keeping the secret key server-side. See
Authentication.
Step 3: Handle next_action
Most local rails need the customer to do something after confirm. The confirm response comes
back with status: "requires_customer_action" and a next_action object:
{
"payment_id": "pay_N5cPeGw6uS2QIMjnsjVF",
"status": "requires_customer_action",
"amount": 50000,
"currency": "INR",
"payment_method": "upi",
"payment_method_type": "upi_intent",
"next_action": { "…": "…" },
"return_url": "https://yourshop.com/checkout/complete"
}
next_action describes what the customer must do. Its inner shape depends on the action, so
treat it as opaque: hand the customer to it without branching on the method. The three
shapes you will encounter, and what to do with each:
| Action kind | What the customer does | What you do |
|---|---|---|
| Redirect | Completes on a page the rail hosts (for example a 3DS or bank page) | Send the customer to the provided URL; they return to your return_url |
| In-app approval | Approves a prompt in a bank or wallet app | Show a "we sent you a prompt, approve it, we'll update this page" state and wait for the webhook |
| Reference to pay | Pays to displayed account / reference details (for example a virtual account or voucher) | Display the details for the customer to pay, then wait for the webhook |
The safe pattern that works across all three without special-casing:
function handleNextAction(payment, res) {
switch (payment.status) {
case "requires_customer_action": {
// Forward to a redirect if the action provides a URL; otherwise render a
// wait screen. Treat next_action as opaque: read a URL if present, else display.
const url = extractRedirectUrl(payment.next_action); // may be undefined
if (url) return res.redirect(303, url);
return res.render("await-approval", { next_action: payment.next_action });
}
case "processing":
return res.render("processing"); // rail is settling; wait for the webhook
case "succeeded":
return res.render("thank-you");
case "failed":
return res.render("try-again", { error_code: payment.error_code });
default:
return res.render("processing");
}
}
After the customer acts, the payment moves to processing and resolves via webhook. Do not
poll in a tight loop. Wait for the webhook, and keep GET /payments/{payment_id} for
reconciliation and recovery.
Step 4: Confirm the outcome by webhook
The customer returning to return_url is a UX signal, not proof of payment. Treat a payment as
paid only on the payment_succeeded webhook (or a retrieve returning succeeded). The webhook
content.object is this same payment object. See Handle webhooks.
Handling declines
A declined collection is not an HTTP error. The call succeeds and the payment object
reports status: "failed" with a stable error_code:
{
"payment_id": "pay_N5cPeGw6uS2QIMjnsjVF",
"status": "failed",
"error_code": "ERR_4094",
"error_message": "The payment was declined."
}
failed is terminal for that payment. When the customer wants to try again, create a new
payment with a new Idempotency-Key; do not re-confirm the failed one. Before you ever see
failed, the platform may already have re-attempted a recoverable decline on an alternative
route inside the same payment. See Error intelligence and
Orchestration and routing.
Complete worked example
Server-side create then confirm, driving the customer through next_action, with fulfilment on
the webhook. (Webhook verification is shown in full in Handle webhooks.)
const express = require("express");
const app = express();
const SECRET = process.env.TENSORRAIL_SECRET_KEY;
async function tr(path, body) {
const r = await fetch(`https://api.tensorrail.com${path}`, {
method: "POST",
headers: { "api-key": SECRET, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return r.json();
}
// Customer submits your checkout form.
app.post("/pay", express.json(), async (req, res) => {
const { order_id, method, method_type, method_data, email } = req.body;
const payment = await tr("/payments", {
amount: 50000,
currency: "INR",
return_url: "https://yourshop.com/checkout/complete",
metadata: { order_id },
});
await orders.setPaymentId(order_id, payment.payment_id);
const confirmed = await tr(`/payments/${payment.payment_id}/confirm`, {
payment_method: method,
payment_method_type: method_type, // always send this
payment_method_data: method_data,
customer: { email },
return_url: "https://yourshop.com/checkout/complete",
});
return handleNextAction(confirmed, res); // from Step 3
});
// Webhook: verify (see Handle webhooks), then fulfil once.
app.post("/webhooks/tensorrail", express.raw({ type: "*/*" }), (req, res) => {
if (!verifyTensorRailWebhook(req.body, req.get("TensorRail-Signature"), process.env.TENSORRAIL_WEBHOOK_SECRET)) {
return res.status(400).end();
}
const event = JSON.parse(req.body.toString());
const payment = event.content.object;
if (payment.status === "succeeded") fulfilOnce(payment.metadata.order_id);
if (payment.status === "failed") markFailed(payment.metadata.order_id);
res.status(200).end();
});
Next steps
Your own checkout can create, confirm, and drive next_action. Go deeper:
- Accept a payment: the full create / confirm / decline / capture reference for this path, with every field.
- Capture and cancel: switch to authorize-then-capture when you ship before you charge.
- Handle webhooks: the authoritative outcome signal behind your fulfilment.
TensorRail, Limassol, Cyprus.