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.
Create a link without code
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.
Create a link from the API
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.
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.
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
- Node
- Python
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"
}'
const response = await fetch(
"https://api.tensorrail.com/v1/payment-links/prepare",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TENSORRAIL_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
amount_minor: 50000,
currency: "INR",
description: "Invoice INV-3391",
}),
},
);
const prepared = await response.json();
import requests
response = requests.post(
"https://api.tensorrail.com/v1/payment-links/prepare",
headers={"Authorization": "Bearer rail_full_test_xxx"},
json={
"amount_minor": 50000,
"currency": "INR",
"description": "Invoice INV-3391",
},
)
prepared = response.json()
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…¤cy=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.
amount and currency in the link are what the hosted page displays. The server builds
url from the amount and currency the engine 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¤cy=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.
Listing your links
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
| Rule | Detail |
|---|---|
| A link is just a URL | Send 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 payment | Each link maps to one payment_id. Do not reuse a link across customers or collections — create a fresh link per invoice. |
| Valid for 24 hours | A payment that is never completed expires after that window. See expires_on on the payment object and the payment_expired webhook. |
| Expired means finished | Treat 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 thepayment_idfrom 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_succeededwebhook (or a retrieve returningsucceeded), 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.