Skip to main content

One-time payments with a link

Somebody owes you a specific amount, once. An invoice, a deposit on a job, a bill agreed over a phone call, a booking. You want to send them a URL, have them pay, and be told when it is done.

There is no page to build and no integration to write. This is the fastest thing in the product to get working and it is worth knowing exactly where its edges are.

Payment linkPayment button
Where the payer isOn a page we host, at a URL you sentOn your own checkout
AmountFixed on the link, when you create itFrom your cart, per purchase
PayersOneOne
AddressOne, belonging to the linkOne per checkout session
Closes whenPaid, or a date passesPaid, or the countdown expires
You writeNothingA few lines of JavaScript

A link is not a factory that mints a session per visitor. It owns an address and a running total. That is why it has no countdown — a countdown is a per-visitor timer and a link has no visitors, only payers.

If you are billing programmatically, at volume, from a system that already knows the total, use the payment button instead. Links are for the cases where a human decided the amount.

1. Create it

curl -X POST $CHAINOS/v1/payments/links \
-H "X-API-Key: $CHAINOS_PAYMENT_SECRET" \
-H "Content-Type: application/json" \
-d '{
"mode": "fixed",
"chain": "base",
"asset": "USDC",
"amount": "1250.00",
"title": "Invoice 4418",
"description": "Consulting, August 2026",
"collectEmail": true,
"successUrl": "https://your-company.example/paid",
"metadata": { "invoiceId": "INV-4418", "customerId": "cust_88213" }
}'
{
"id": "0b1f…",
"slug": "k3Xq7RfN2p",
"url": "https://pay.your-company.example/pay/k3Xq7RfN2p",
"mode": "fixed",
"amountRaised": "0",
"status": "active",
"address": null
}

Send url. That is the whole integration.

The slug is generated and cannot be chosen on a fixed link

A guessable slug on an invoice publishes what one customer was charged. Anyone who can guess /pay/invoice-4418 learns your client's rate. Custom slugs are honoured on donation links only, where the page is meant to be found.

The fields that decide how it behaves

FieldWhy it matters
chain + assetFixed at creation. They are what the address was derived for and cannot be changed
amountA string, in the asset's own units: "1250.00". More decimal places than the asset carries is refused, not rounded
collectEmailTurn it on. Without an address there is nobody to send the receipt to and nobody to contact if they underpay
metadataYour own identifiers, echoed on every webhook. Put your invoice id here
closesAtOptional on a fixed link. Useful for a quote that expires
successUrlWhere the payer lands afterwards. Yours; nothing invents one

amount is a decimal string and never a JSON number. JSON has one numeric type and it is a double, and this figure decides whether an invoice is settled.

2. Choose the chain deliberately

The payer pays on the chain you picked. They cannot change it, and they will not necessarily tell you they could not.

curl -s $CHAINOS/v1/payments/readiness -H "X-API-Key: $CHAINOS_PAYMENT_SECRET"

Checkout runs on seven chains — Bitcoin, Ethereum, BNB Smart Chain, Polygon, Avalanche, Base and TRON. Solana, Stellar and XRP are refused at creation with the reason stated.

For an invoice the practical shortlist is:

Base or Polygon, USDCCheap to receive, cheap to sweep, native issuance
TRON, USDTWhat a great many counterparties already hold
BSC, USDTCheap, widely held. Note USDT is 18 decimals here, not 6
EthereumOnly for large amounts. A small invoice costs more in gas to collect than it is worth

A session below the chain's economic minimum is refused at creation, naming a cheaper chain. nativeMinimum and estimatedSweepFee on the readiness response are the numbers to steer by, and this is the only moment it is free to fix.

Say the network in the email, not just the asset

"1,250 USDC" is ambiguous — USDC exists on five chains here and the same address format covers five of them. The hosted page says Send only USDC on Base above the fold; your covering email should say it too. A payer who sends on Ethereum to a Base-quoted address has sent real money to a real address you control, which is recoverable, and it will cost both of you a day.

3. Know when it is paid

Payments through a link fire the same events as any other checkout, with linkId set and createdVia: "link".

curl -X POST $CHAINOS/v1/webhooks \
-H "X-API-Key: $CHAINOS_KEY" -H "Content-Type: application/json" \
-d '{ "url": "https://your-company.example/webhooks/chainos",
"events": ["payment_confirmed", "payment_underpaid", "payment_late",
"payment_link_closed"] }'
async function handle(event) {
const invoiceId = event.data.metadata?.invoiceId;

switch (event.event) {
case 'payment_confirmed':
// The only event that marks an invoice paid. `detected` can still be
// reorganised away.
await markPaid(invoiceId, event.data.amountPaid, event.data.txid);
break;

case 'payment_underpaid':
// Real money arrived, just not enough. The link stays open.
await askForRemainder(invoiceId, event.data.amountRemaining);
break;

case 'payment_late':
// Paid after the closing date. Still real, still credited.
await reviewLatePayment(invoiceId);
break;

case 'payment_link_closed':
await recordClosure(invoiceId, event.data.closedReason);
break;
}
}

Verify the signature over the raw bytes of the request, not a re-serialised object. See Webhooks.

If you would rather poll than receive, GET /v1/payments/links/{id} carries amountRaised, paymentCount and status. amountRaised is confirmed money only — a total that counted unconfirmed deposits could go backwards on a page somebody is watching.

4. What the payer gets

An invoice is issued and emailed automatically on the first confirmation, numbered INV-2026-000123. The number is per account, per year, so it does not tell your customer how many invoices the whole platform has issued.

It is a hosted HTML page with a print stylesheet rather than a PDF. Resending (POST /v1/payments/invoices/{id}/resend) mints a new link and stops the old one working, which is what you want when the first email went astray.

5. Closing, pausing, and the deadline that is not a deadline

A fixed link closes itself when it is paid. closedReason says which condition fired:

closedReason
paidIt took its payment
dateclosesAt passed
manualSomebody closed it

Closing is irreversible and freezes the total. To take a page down temporarily, pause it — the total is kept and resuming puts it back.

curl -X POST $CHAINOS/v1/payments/links/$ID/pause -H "X-API-Key: $KEY"
curl -X POST $CHAINOS/v1/payments/links/$ID/resume -H "X-API-Key: $KEY"
curl -X POST $CHAINOS/v1/payments/links/$ID/close -H "X-API-Key: $KEY"
A closed link does not mean money sent to it is lost

The address keeps being monitored until monitorUntil, weeks later, and late funds are credited. A customer who pays an invoice three days after you closed it has sent real money.

This is the single most common source of "the customer says they paid and we have no record" in any crypto integration. Subscribe to payment_late and give it a handler, not a default branch.

6. Editing

PATCH /v1/payments/links/{id} changes presentation and closing conditions: title, description, successUrl, collectEmail, minAmount, maxAmount, closesAt, maxUses, metadata.

It does not change the chain, the asset, the mode or a fixed link's amount. Those are what the address was derived for and what has already been quoted; changing them would rewrite the meaning of money that may already be in flight. Close the link and issue a new one.

7. Sweep what you collect

Each link owns an address. Fifty invoices is fifty addresses holding fifty balances, and on an account-model chain you cannot spend them together.

Configure a treasury wallet and a sweep policy for each (chain, asset) you invoice in — see Customer wallets §8. Sweeps are never billed, so do it as often as the network fee justifies.

In the portal

Payment links in the sidebar: mode, raised against target, what will close it and when, and copy-URL, pause and close on each row. Closing sits behind a confirmation because it cannot be undone.

Next