Skip to main content

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 -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" }
}'

Create request fields

FieldTypeRequiredNotes
amountintegerYesSmallest currency unit (50000 = INR 500.00)
currencystringYesISO 4217. Not validated against your corridors at create — see the caution below
payment_idstringNoYour id for the payment (max 64 chars); auto-generated if omitted; must be unique — reusing one is rejected with ERR_3018
confirmbooleanNoConfirm in the same call (server-side flows where the method is already known)
capture_methodautomatic | manualNoDefault automatic
customer_idstringNo
descriptionstringNo
return_urluriNoRequired for redirect-based methods, and required whenever payment_link is true
setup_future_usageon_session | off_sessionNoSave the method for reuse
statement_descriptor_namestringNo
payment_linkbooleanNotrue returns a hosted-checkout link on the response
metadataobjectNoYour own key-value data; echoed on the object and every webhook, so put your order id here
The currency is not checked against your corridors

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-Key header. 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 own payment_id.
  • Your own payment_id. Creation is unique on payment_id; retrying a create with the same payment_id is rejected with ERR_3018 (duplicate). If your system already has a natural unique order reference, supplying it as payment_id guarantees at most one payment exists for it — on ERR_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 -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"
}'

Confirm request fields

FieldNotes
payment_methodThe method family (for example card, upi)
payment_method_typeThe specific type. Send it explicitly; omitting it can block later refunds
payment_method_dataThe method-specific input the rail needs
customerThe 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
billingBilling details for the payer. Send billing.email alongside customer.email; rails that want a payer name or phone read it from here
return_urlWhere to send the customer after a redirect
client_secretBrowser + 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_action at 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_action without branching on payment_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:

FieldUse it for
sdk_uriA 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_urlA 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.

caution

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_code is a stable code from the unified catalog; it means the same thing whatever rail was behind the attempt. See Error intelligence.
  • failed is terminal for this 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; 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:

StatusWhat to do
requires_payment_methodPresent methods; confirm
requires_confirmationCall confirm
requires_customer_actionForward the customer to next_action
processingWait for the webhook
requires_captureCapture the authorized payment (manual capture)
succeededFulfil the order (confirm via webhook)
partially_capturedFulfil at the captured amount; a partial capture landed here (see Capture and cancel)
failedRead error_code / error_message; offer a new attempt
cancelledNothing further
expiredThe 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_captureCancelled after capture. Money that had been captured is being returned; reconcile as you would a full refund
requires_merchant_actionWaiting on you, not the customer — manual review. The payment sits here until you act, so surface it to an operator rather than polling
conflictedThe amount or currency we sent does not match what the processor reports. Do not fulfil. Contact support with the payment_id
partially_captured_and_capturablePart captured, more still capturable. Fulfil at the captured amount and capture again if you intend to take the rest
partially_authorized_and_requires_captureOnly part of the amount was authorized and is awaiting capture. Decide whether the partial amount is acceptable before capturing
partially_captured_and_processingPart 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:

TensorRail, Limassol, Cyprus.