Skip to main content

Payment links

A payment link is a shareable hosted-checkout URL: the same TensorRail-hosted payment page as hosted checkout, but sent to the customer instead of redirected to. You send the link by email, chat, invoice, or social, and the customer opens it and pays. The outcome lands on the same webhooks, the same balance, and the same reporting as every other payment.

This is one of three integration paths. It needs the least engineering of the three: you can create a link with no code from the dashboard, or with a single API call.

When to use it​

  • Invoicing. Attach a pay-now link to an invoice or quote.
  • No website, or no dev time yet. Collect before you have a checkout built.
  • Sales-led or social. Send a link over chat, email, or a social channel.
  • One-off collections. Deposits, custom orders, ad-hoc charges.

If you want to redirect a customer mid-checkout on your own site, use hosted checkout. If you want the payment experience inside your own UI, use the direct API.

From the dashboard: Payment Links → enter amount, currency, and an optional description → Create. You get a URL to copy and share. This needs no engineering at all, and the resulting payment flows into the same reporting and webhooks as any API-created payment.

One call prepares the collection, and you build the shareable URL from its response. It produces the same kind of pay.tensorrail.com link as the dashboard's Payment Links page, the hosted page described in hosted checkout.

Payment links are enabled per account

If the endpoint answers 403 with ERR_1012, payment links are not enabled for your account. That is different from ERR_1004, which means the credential you used lacks a scope it could be granted: ERR_1012 is the account, not the key, and no change to your key will move it. Ask support to enable the feature.

The link's environment follows your API key. Prepare with a rail_*_test_* key and the link runs against the sandbox (test money, test methods); prepare with a rail_*_live_* key and the link collects real money. A live prepare on an account that has not completed live activation is refused with 409: you cannot create a live-looking link that silently routes to the sandbox.

Step 1: Prepare the collection​

POST /v1/payment-links/prepare creates the underlying payment for the link and returns the identifiers you need. It lives on the /v1 surface, so authenticate with your secret key (rail_full_test_… / rail_full_live_…) as a bearer token; see Authentication.

Use the secret key, not the publishable key

Both key types begin rail_, which makes them easy to confuse. Prepare needs the payments:write scope, and a publishable key (rail_open_…) never carries it: passing one fails with ERR_1002 invalid_api_key, and passing it without Bearer fails with ERR_1011 unauthorized. The publishable key is what this endpoint returns to you, for the browser step that follows.

Body: amount_minor (required), currency (required), description (optional), and return_url (optional: where the payer lands after completing payment; defaults to the hosted page's completion screen).

curl -X POST https://api.tensorrail.com/v1/payment-links/prepare \
-H "Authorization: Bearer rail_full_test_xxx" \
-H "Content-Type: application/json" \
-d '{
"amount_minor": 50000,
"currency": "INR",
"description": "Invoice INV-3391"
}'

The response carries the prepared collection:

{
"payment_id": "pay_N5cPeGw6uS2QIMjnsjVF",
"client_secret": "pay_N5cPeGw6uS2QIMjnsjVF_secret_k9Yt…",
"publishable_key": "rail_open_test_xxx",
"amount_minor": 50000,
"currency": "INR",
"url": "https://pay.tensorrail.com/?amount=50000&client_secret=pay_N5cPeGw6uS2QIMjnsjVF_secret_k9Yt…&currency=INR&mode=link&payment_id=pay_N5cPeGw6uS2QIMjnsjVF&pk=rail_open_test_xxx"
}

Step 2: Send the URL​

Use the url from the response. It is the finished shareable link; send it to the customer as-is. It opens the same hosted page as hosted checkout.

Each link maps to exactly one payment_id, so a link is single-use for one collection: once that payment succeeds, it is done.

Do not assemble the URL by hand

amount and currency in the link are what the hosted page displays. The server builds url from the amount and currency the platform actually created, so the payer always sees the figure they will be charged. Typing the URL yourself reintroduces the ways it can go wrong: a missing client_secret (the page then has no payment to load), a mistyped pk (method enumeration fails), or a display amount that does not match the payment.

If you must construct it yourself (an older integration, or a link rebuilt from stored identifiers), the shape is:

https://pay.tensorrail.com/?mode=link&payment_id=PAYMENT_ID&client_secret=CLIENT_SECRET&pk=PUBLISHABLE_KEY&amount=50000&currency=INR

All of mode, payment_id, client_secret and pk are required. A URL that says mode=link but omits payment_id or client_secret is rejected with Invalid checkout link rather than rendering a partial page.

GET /v1/payment-links on the account and reporting API returns the links your account has saved, for reconciliation and display. To make a prepared link appear in that list, persist it with POST /v1/payment-links (amount_minor, currency, optional description, and the built url); links created from the dashboard are saved there automatically.

Sharing and expiry​

RuleDetail
A link is just a URLSend it however you reach the customer: email, chat, invoice PDF, a message. It opens the same hosted page as hosted checkout, so every enabled method is presented and new methods appear automatically.
One link, one paymentEach link maps to one payment_id. Do not reuse a link across customers or collections; create a fresh link per invoice.
How long a link lives is set per paymentLinks created from the dashboard, and through the payment-links endpoint, are given 24 hours. A link you get back from POST /payments with payment_link: true uses that payment's session_expiry, which defaults to 900 seconds (fifteen minutes), so pass session_expiry explicitly when you are building a link to send rather than a browser to redirect. expires_on on the payment object is the authority either way, and the payment_expired webhook fires when the window closes.
UPI has its own rulesA per-payment amount range, a figure the payer approves that can sit slightly below the link amount, and refunds returned as a payout. Read the UPI rules before you send a UPI link.
Expired means finishedTreat it like a failed attempt. If the customer still wants to pay, create a new payment and a new link rather than reviving the old one.

Reconciling the resulting payment​

A payment created by a link is an ordinary payment, so you reconcile it exactly like any other:

  • Join by payment_id. Store the payment_id from the prepare response against your invoice or order. It is on the payment object and on every webhook, so links you send by hand still land in your automated fulfilment and reporting.
  • Confirm by webhook. Treat the invoice as paid only on the payment_succeeded webhook (or a retrieve returning succeeded), never because the customer opened the link. Local rails complete asynchronously.
  • Reconcile in reporting. The payment appears in your transaction history and exports the same as any collection, and posts against the same ledger under your balance. See Money integrity.

Complete worked example: invoice a customer​

const SECRET = process.env.TENSORRAIL_SECRET_KEY;

async function sendInvoiceLink(invoice) {
// 1. Prepare the collection behind the link.
const r = await fetch("https://api.tensorrail.com/v1/payment-links/prepare", {
method: "POST",
headers: {
Authorization: `Bearer ${SECRET}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
amount_minor: invoice.amount_minor,
currency: invoice.currency,
description: `Invoice ${invoice.number}`,
}),
});
const prepared = await r.json();

// 2. Build the shareable hosted-page URL.
const url = new URL("https://pay.tensorrail.com/");
url.searchParams.set("mode", "link");
url.searchParams.set("payment_id", prepared.payment_id);
url.searchParams.set("client_secret", prepared.client_secret);
url.searchParams.set("pk", prepared.publishable_key);
url.searchParams.set("amount", String(prepared.amount_minor));
url.searchParams.set("currency", prepared.currency);

// 3. Store the link against the invoice (join by payment_id), then email it.
await invoices.attachPayment(invoice.number, prepared.payment_id, url.toString());
await email.send(invoice.customer_email, {
subject: `Invoice ${invoice.number}`,
body: `Pay securely here: ${url.toString()}`,
});
}

// 4. Mark the invoice paid from the webhook, never from a page open.
async function onWebhook(event) {
if (event.content.object.status === "succeeded") {
const invoice = await invoices.findByPaymentId(event.content.object.payment_id);
if (invoice) invoices.markPaid(invoice.number);
}
}

Next steps​

You can send a payment link. To close the loop:

  • Handle webhooks: mark the invoice paid from the authoritative event, not a page open.
  • Hosted checkout: the same page redirected to mid-checkout when you want an inline flow.
  • Testing and sandbox: exercise a link end to end on test keys before going live.

TensorRail, Limassol, Cyprus.