Coinland PayDocs
Concepts

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}/cancel
  • open is 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.
  • completed is terminal, and the session carries its payment object 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.
  • expired happens on its own at expires_at. Nothing was reserved and nothing moved, so an expired session costs nobody anything.
  • canceled is 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 with PAY_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 sendYou get
A new reference_idA new session (201)
The same reference_id, identical payloadThe original session, unchanged
The same reference_id, different payloadPAY_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 amounts is 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 currency must be in your accepted_currencies from GET /api/pay/v1/me, or the whole request is refused with PAY_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.
  • amount is a decimal string: "24.90", not 24.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.

On this page