Sessions
The checkout session lifecycle, idempotency on reference_id, and why you price in every coin you accept
A checkout session is one offer to one customer: this order, at these prices, until this deadline. You create it, the customer pays it, and it ends in exactly one of three states.
Everything about a session is decided when you create it. There is no update call -- if a price
changes, cancel the session and create a new one with a new reference_id.
Lifecycle
┌── completed the customer paid; a payment row exists
│
open ──────┼── expired expires_at passed with nobody paying
│
└── canceled you called POST /sessions/{id}/cancelopenis the only state in which the widget will take a payment. The customer may open and abandon the page any number of times while it lasts.completedis terminal, and the session carries itspaymentobject from then on. Money that has moved stays moved: there is no cancel, no reverse and no partial capture on this rail. If you need to give money back, send the customer a transfer.expiredhappens on its own atexpires_at. Nothing was reserved and nothing moved, so an expired session costs nobody anything.canceledis you deciding the order is dead before it was paid. Cancelling is idempotent: cancelling an already-cancelled session returns it unchanged rather than erroring. Cancelling a completed one is refused withPAY_SESSION_STATE(409).
You never have to poll for expiry. Coinland fires a session.expired webhook, and
GET /api/pay/v1/sessions/{id} always reports the current state.
Session lifetime
The default lifetime comes from your business console. Override it per session with ttl_minutes,
between 5 and 1440 (24 hours).
Pick it to match what you are selling. A short TTL is right when the thing being bought is scarce or priced against a moving market, because the price you quoted stops being the price you would quote after a while -- Coinland does no conversion, so an hour-old BTC price is simply an hour-old BTC price. A long TTL is right for an invoice you expect somebody to pay tomorrow. Both are honest; choosing thoughtlessly is what causes trouble.
Idempotency
reference_id is your order id and it is also your idempotency key. There is no separate
Idempotency-Key header on this API.
| You send | You get |
|---|---|
A new reference_id | A new session (201) |
The same reference_id, identical payload | The original session, unchanged |
The same reference_id, different payload | PAY_DUPLICATE_REFERENCE (409) |
That is the whole rule, and it is what makes retrying safe. A network timeout on
POST /sessions tells you nothing about whether the session was created, so the correct response is
to send the same request again -- not to generate a fresh id, which is how one order becomes two
sessions and, eventually, two payments.
Never mint a new reference_id to retry
The 409 on a changed payload is a feature: it catches the case where you reused an order id for a
different order. If you legitimately need different prices for the same cart, that is a new order in
your own system, and it gets a new reference_id.
reference_id is unique across your account forever, not just among open sessions. It is also a
lookup key: GET /api/pay/v1/sessions/{id} accepts either the session UUID or your own
reference_id, so you can read a session without having stored anything we generated.
Pricing in multiple coins
amounts is a list of {currency, amount} pairs, up to ten, and the customer picks exactly one.
"amounts": [
{ "currency": "usdt", "amount": "24.90" },
{ "currency": "btc", "amount": "0.00027" },
{ "currency": "eth", "amount": "0.0069" }
]Coinland converts nothing. Each entry is an independent price for the same order, and whichever one the customer chooses is the exact amount debited from them. This rail quotes no spot rate, applies no spread, and will not turn one coin into another.
The consequence is that cross-coin consistency is your job. If you list a USDT price and a BTC price, you are the one who decided they are worth the same thing, and you are the one carrying the risk if the market moves before the session is paid. Two ways to handle that:
- Quote from a live rate at session creation and use a short
ttl_minutes, so the window in which the market can move against you is small. - List one coin only. A single-entry
amountsis completely normal, and it makes the exposure question disappear.
Toman is not accepted
Coinland Pay supports cryptocurrencies only; Toman (IRT) payments are not offered on this rail. It
cannot be selected as an accepted currency, and putting it in amounts is refused with
PAY_CURRENCY_NOT_ACCEPTED.
Rules that bite:
- Every
currencymust be in youraccepted_currenciesfromGET /api/pay/v1/me, or the whole request is refused withPAY_CURRENCY_NOT_ACCEPTED(422). Read that list at startup instead of hard-coding coins, because it changes in the console without a deploy on your side. amountis a decimal string:"24.90", not24.9. Floats cannot represent every decimal amount, and a rounding error here is a rounding error in what you are paid.- Amounts must be positive and within the coin's precision.
PAY_AMOUNT_INVALID(422) covers a non-positive amount, too many decimal places for the coin, and anything outside the platform's bounds. - One coin appears once. Two entries for the same currency is a bug in your pricing code, not a choice between two prices.
Pricing in USD
Instead of amounts, send a single price_usd and Coinland quotes it in every coin you accept,
at the live rate, at the moment the session is created.
{
"reference_id": "order-10492",
"title": "Order 10492",
"price_usd": "24.90"
}Send amounts or price_usd — never both, never neither. Either mistake is
PAY_AMOUNT_INVALID (422).
The session comes back with pricing_mode: "usd", the price_usd you sent, and an amounts array
of the quotes, each carrying the usd_value it was derived from. From then on it behaves exactly
like a per-coin session: the customer picks one coin and pays that figure.
Those quotes ARE the rate lock
There is no separate rate-lock timer to reason about. The quotes are taken once, at creation, and
the session's own expires_at is the window they are good for. The customer pays the coin amount
they were shown. If they take too long the session expires and you create a new one at the rate of
that moment.
A coin with no live rate is silently omitted. Your other accepted coins still work, and the
customer simply sees fewer options. Only if nothing can be priced is the request refused, with
PAY_RATE_UNAVAILABLE (503) — a well-formed request that failed on our side, so retry it rather
than change it.
Idempotency covers the USD figure, not the quotes
Retrying the same reference_id with the same price_usd replays the original session, quotes
and all, even though the live rate has moved since and fresh quotes would differ. The idempotency
fingerprint is taken over the USD figure you sent, not over the coin amounts derived from it. That is
what makes a retry safe: you get back the offer your customer is already looking at, not a repriced
one.
A different price_usd on the same reference_id is still a conflict, and still answers
PAY_DUPLICATE_REFERENCE (409).
Metadata
metadata is a free-form JSON object, echoed back on every read of the session and on the payment.
It is the right place for your cart id, your channel, your campaign -- anything you would otherwise
have to look up.
It is visible to nobody but you, but it is stored, so keep secrets and personal data out of it. Your own ids are the intended content.
Reading a session
const API = "https://my.coinlandexchange.com";
const auth = { Authorization: `Bearer ${process.env.COINLAND_PAY_KEY}` };
// Either the session UUID or your own reference_id works as the id.
export async function getSession(id: string) {
const res = await fetch(`${API}/api/pay/v1/sessions/${encodeURIComponent(id)}`, {
headers: auth,
});
if (!res.ok) throw new Error(`coinland pay ${res.status}`);
return res.json();
}
// Idempotent: cancelling a cancelled session returns it unchanged.
export async function cancelSession(id: string) {
const res = await fetch(
`${API}/api/pay/v1/sessions/${encodeURIComponent(id)}/cancel`,
{ method: "POST", headers: auth },
);
if (!res.ok) throw new Error(`coinland pay ${res.status}`);
return res.json();
}This is the authoritative state. When status is completed, the full
payment is embedded in the response, so one call answers both "did they pay"
and "what exactly did they pay".
An unknown id -- or one belonging to another business -- answers PAY_SESSION_NOT_FOUND (404). Those
two cases are deliberately indistinguishable: a different answer for "exists but is not yours" would
let anyone with a key enumerate other businesses' orders.