Accept a payment
This guide covers the create-and-confirm flow with the real request and response fields, plus the edge cases a production integration must handle: declines, retries, customer actions, and manual capture. It is the direct-API path; if you use the hosted page you only need the create call and a redirect (see Choose your integration).
The whole flow, before the detail:
Create a payment
POST /payments. amount and currency are required; currency must be a corridor enabled
for your account.
- 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",
"capture_method": "automatic",
"description": "Order #12345",
"return_url": "https://yourshop.com/checkout/complete",
"metadata": { "order_id": "12345" }
}'
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",
capture_method: "automatic",
description: "Order #12345",
return_url: "https://yourshop.com/checkout/complete",
metadata: { order_id: "12345" },
}),
});
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",
"capture_method": "automatic",
"description": "Order #12345",
"return_url": "https://yourshop.com/checkout/complete",
"metadata": {"order_id": "12345"},
},
)
payment = response.json()
Create request fields
| Field | Type | Required | Notes |
|---|---|---|---|
amount | integer | Yes | Smallest currency unit (50000 = INR 500.00) |
currency | string | Yes | ISO 4217. Not validated against your corridors at create — see the caution below |
payment_id | string | No | Your id for the payment (max 64 chars); auto-generated if omitted; must be unique — reusing one is rejected with ERR_3018 |
confirm | boolean | No | Confirm in the same call (server-side flows where the method is already known) |
capture_method | automatic | manual | No | Default automatic |
customer_id | string | No | |
description | string | No | |
return_url | uri | No | Required for redirect-based methods, and required whenever payment_link is true |
setup_future_usage | on_session | off_session | No | Save the method for reuse |
statement_descriptor_name | string | No | |
payment_link | boolean | No | true returns a hosted-checkout link on the response |
metadata | object | No | Your own key-value data; echoed on the object and every webhook, so put your order id here |
POST /payments accepts any well-formed ISO 4217 code, whether or not your account has a
corridor for it — only a malformed code is rejected (ERR_2002). A currency you cannot
settle fails later at confirm with a route-configuration error (ERR_4088), which your
CUSTOMER sees rather than your server. The same code covers a processor rejecting the credentials
configured for the route — see the error catalogue. So validate before you create. The corridors and methods
enabled on your account are shown in your dashboard and confirmed by your account manager;
GET /v1/capabilities is the platform-wide
catalogue of what TensorRail supports, not a per-account list.
The create response
{
"payment_id": "pay_N5cPeGw6uS2QIMjnsjVF",
"status": "requires_payment_method",
"amount": 50000,
"net_amount": 50000,
"amount_capturable": 0,
"amount_received": null,
"currency": "INR",
"client_secret": "pay_N5cPeGw6uS2QIMjnsjVF_secret_k9Yt…",
"capture_method": "automatic",
"description": "Order #12345",
"metadata": { "order_id": "12345" },
"return_url": "https://yourshop.com/checkout/complete",
"payment_method": null,
"payment_method_type": null,
"next_action": null,
"error_code": null,
"error_message": null,
"created": "2026-07-22T09:00:00.000Z",
"modified_at": "2026-07-22T09:00:00.000Z"
}
Persist payment_id against your order before doing anything else. client_secret is the
browser-safe credential for this one payment: pass it to the hosted page or your client-side
confirm, never log it, and never expose one payment's secret to another session.
Idempotent creation
Creation is safe to retry through two complementary layers:
Idempotency-Keyheader. Reusing the same key deterministically replays the first request: you get the original payment back, whatever the retry's body says. A retry can never produce a second collection. There is no body-mismatch rejection on this path, so never reuse a key with a different body — the differing body is silently ignored. Use one fresh UUID per logical payment attempt and reuse it for network retries of that attempt only. The header is honored only when the body does not supply its ownpayment_id.- Your own
payment_id. Creation is unique onpayment_id; retrying a create with the samepayment_idis rejected withERR_3018(duplicate). If your system already has a natural unique order reference, supplying it aspayment_idguarantees at most one payment exists for it — onERR_3018, retrieve the existing payment.
Either way, a timeout-and-retry, a double-clicked checkout button, or a replayed job can never produce two collections. See Orchestration and routing.
Confirm the payment
POST /payments/{payment_id}/confirm with the customer's chosen method.
- 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" },
"return_url": "https://yourshop.com/checkout/complete"
}'
const response = await fetch(
"https://api.tensorrail.com/payments/pay_N5c…/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" },
return_url: "https://yourshop.com/checkout/complete",
}),
}
);
const payment = await response.json();
import requests
response = 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"},
"billing": {"email": "buyer@example.com"},
"return_url": "https://yourshop.com/checkout/complete",
},
)
payment = response.json()
Confirm request fields
| Field | Notes |
|---|---|
payment_method | The method family (for example card, upi) |
payment_method_type | The specific type. Send it explicitly; omitting it can block later refunds |
payment_method_data | The method-specific input the rail needs |
customer | The payer. Some rails require customer.email at confirm and reject the confirm without it — the live UPI rail is one, so send it. Every worked example on this page does |
billing | Billing details for the payer. Send billing.email alongside customer.email; rails that want a payer name or phone read it from here |
return_url | Where to send the customer after a redirect |
client_secret | Browser + publishable-key confirms only; a secret-key confirm rejects it (ERR_2002) |
Confirm can also run from the browser with your publishable key plus the payment's
client_secret, which is how the hosted page works; a secret key stays server-side either
way. See Authentication.
The confirm response and next_action
For an asynchronous local rail the confirm response typically comes back waiting on the customer:
{
"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, for example follow a redirect, approve in a
bank or wallet app, or pay to displayed reference details. Its inner shape depends on the
action, so treat it as opaque: forward the customer to it (or render it) without branching
on the method, and your checkout keeps working as new methods are enabled. The concrete
per-action shapes are in the API reference.
After the customer acts, the payment moves to processing and then resolves via webhook. Do
not poll in a tight loop; wait for the webhook and keep GET /payments/{payment_id} for
reconciliation and recovery paths.
You should not need to write method-specific code
Everything above is deliberately method-agnostic, and that is the point: integrate once, and new payment methods appear as they are enabled on your account — no code change, no redeploy, no new guide to read.
- Hosted Checkout or Payment Links — you never touch
next_actionat all. We render whatever the method requires. This is the recommended path and the reason most merchants never revisit their integration. - Direct API — forward or render
next_actionwithout branching onpayment_method_type. Treat it as opaque and your integration keeps working as methods change.
The rest of this section is a worked example of what next_action can contain, so the shape
is not a mystery. If you use Hosted Checkout or Payment Links you can skip it.
Worked example: a UPI intent
UPI happens to be a method where the customer's device changes what should be rendered, which
makes it a good illustration. On upi_intent, next_action comes back as
invoke_upi_intent_sdk carrying two things:
| Field | Use it for |
|---|---|
sdk_uri | A upi://pay?… link. On a phone, send the customer to it — it opens their UPI app (GPay, PhonePe and Paytm among them) with the amount pre-filled. |
image_data_url | A ready-rendered QR of that same link, as a data: URL. On a desktop, display it — a desktop browser cannot open upi://, so the QR is the only thing the customer can act on. |
A simple rule that covers both: if the device has a coarse pointer, follow sdk_uri;
otherwise show image_data_url. Our hosted checkout does exactly this — which is why the
hosted path needs none of this from you. Other methods carry different fields; the same
"render what you are given" approach covers all of them.
The payment stays in requires_customer_action for the whole time the customer is in their
banking app — that status does not mean something is wrong. next_action.poll_config
tells you how often to re-check while you wait for the webhook.
Prefer upi_intent over upi_collect. The collect flow models "we send a collect request to
the customer's UPI ID", so there is nothing to render at checkout — the customer is left on a
waiting screen with nothing in front of them to pay. upi_intent hands them something to act
on immediately: the link or the QR described above.
Handle a decline
A declined or failed collection is not an HTTP error. The call succeeds and the payment object reports the outcome:
{
"payment_id": "pay_N5cPeGw6uS2QIMjnsjVF",
"status": "failed",
"amount": 50000,
"currency": "INR",
"error_code": "ERR_4094",
"error_message": "The payment was declined."
}
error_codeis a stable code from the unified catalog; it means the same thing whatever rail was behind the attempt. See Error intelligence.failedis terminal for this payment. When the customer wants to try again, create a new payment with a newIdempotency-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; what reaches you is the final outcome.
The payment object
Retrieve a payment any time with GET /payments/{payment_id}. The complete, stable field set
you can see:
payment_id, status, amount, net_amount, amount_capturable, amount_received,
shipping_cost, order_tax_amount, currency, client_secret, next_action, return_url,
payment_link, customer_id, customer, description, metadata, reference_id,
merchant_order_reference_id, capture_method, payment_method, payment_method_type,
setup_future_usage, payment_method_id, mandate_id, expires_on, created,
modified_at, error_code, error_message, error_reason.
Branch on error_code. It is the stable, machine-readable identifier of the failure, and it
is the only one of the three error fields you should write logic against. error_message is
the canonical decline text for its class — it is deliberately identical for every decline of a
class, which makes it useless for diagnosis and no more precise than the code it accompanies —
and, being human-readable, its wording can change. error_reason is populated only on a failed
payment and is the most diagnostically useful of the three: it carries the route's own words
about what went wrong, which is what tells you whether the problem is your request. It is
masked before it is serialised, so it never names a rail or a processor. Read the message and
the reason in your logs and support tooling; branch on the code.
This allowlist is deliberate: it is the whole API contract, and no rail-specific or routing
detail ever appears in it. Webhook content.object payloads include all of these documented
fields but may carry additional fields — parse what you need and ignore the rest, and never
rely on undocumented fields. Full per-field documentation is in the
API reference.
Branch on status:
| Status | What to do |
|---|---|
requires_payment_method | Present methods; confirm |
requires_confirmation | Call confirm |
requires_customer_action | Forward the customer to next_action |
processing | Wait for the webhook |
requires_capture | Capture the authorized payment (manual capture) |
succeeded | Fulfil the order (confirm via webhook) |
partially_captured | Fulfil at the captured amount; a partial capture landed here (see Capture and cancel) |
failed | Read error_code / error_message; offer a new attempt |
cancelled | Nothing further |
expired | The customer never completed it before expires_on. Terminal — treat as failure for fulfilment. This is the single most common terminal outcome on live traffic — a customer abandoning a pending collection is ordinary, and it outnumbers failed several times over. Branch on it explicitly rather than letting it fall through |
cancelled_post_capture | Cancelled after capture. Money that had been captured is being returned; reconcile as you would a full refund |
requires_merchant_action | Waiting on you, not the customer — manual review. The payment sits here until you act, so surface it to an operator rather than polling |
conflicted | The amount or currency we sent does not match what the processor reports. Do not fulfil. Contact support with the payment_id |
partially_captured_and_capturable | Part captured, more still capturable. Fulfil at the captured amount and capture again if you intend to take the rest |
partially_authorized_and_requires_capture | Only part of the amount was authorized and is awaiting capture. Decide whether the partial amount is acceptable before capturing |
partially_captured_and_processing | Part captured; the remainder is still with the processor. Wait for the webhook before treating the total as final |
Always keep a default branch. The statuses above are the values you meet on the paths a
production integration takes, and it is a set that grows as rails are added, so a payment
landing in a status your integration does not recognise should be logged and held — never
silently treated as success. expired is the worked example of why: it is a terminal state on
more than a quarter of live payments, and an integration written against only the first nine
rows of this table has no branch for it.
Expiry also surfaces as expires_on on the payment object and the payment_expired
webhook.
Update before confirm
An unconfirmed payment can be updated with POST /payments/{payment_id} (for example to
adjust the amount after a cart change). Once confirmed, amounts are fixed; corrections happen
through capture (manual flow) or refunds. Updating a payment in a state that
does not allow it returns ERR_3004 (invalid_state_transition).
Manual capture
If you created the payment with capture_method: "manual", an authorized payment sits at
requires_capture (with the authorized value in amount_capturable) until you capture it:
curl -X POST https://api.tensorrail.com/payments/pay_N5c…/capture \
-H "api-key: rail_full_test_xxx" \
-H "Content-Type: application/json" \
-d '{}'
Use this to authorize at checkout and capture on fulfilment. Cancel an uncaptured payment
with POST /payments/{payment_id}/cancel; it moves to cancelled and nothing is collected.
Capturing a payment that is not requires_capture returns ERR_3004.
List payments
GET /payments/list returns your payments, authenticated with your api-key:
curl https://api.tensorrail.com/payments/list \
-H "api-key: rail_full_test_xxx"
The list shape is:
{ "size": 2, "data": [ { "payment_id": "pay_…", "status": "succeeded", "…": "…" }, { "…": "…" } ] }
Each element is the same curated payment object. For finance-grade reconciliation, prefer the dashboard exports described in Money integrity.
Confirm the outcome with a webhook
Treat a payment as paid only on the payment_succeeded webhook (or a retrieve returning
succeeded), never on the customer returning to return_url. The webhook's
content.object carries every documented payment field, so the fulfilment code you write
against the payment object works on it too; the payload may carry additional fields — parse
what you need, ignore the rest, and never rely on undocumented fields. See
Handle webhooks.
Next steps
You can create, confirm, capture, and read the outcome. Build on it:
- Handle webhooks: the authoritative outcome contract, in full.
- Refunds: return money on a succeeded payment, full or partial.
- Capture and cancel: authorize now and capture on fulfilment.
TensorRail, Limassol, Cyprus.