Coinland PayDocs

Widget

Three ways to take a payment -- hosted redirect, popup, and an embedded inline card -- and why confirmation always happens on Coinland's origin

The widget is where the customer actually pays. Confirmation always happens on Coinland's own origin at https://my.coinlandexchange.com/pay/{sessionId}; what you choose is how the customer gets there and how much of the order appears on your page first.

All three start from a session you have already created server-side. The widget itself never takes your API key, and there is nothing to configure in it -- the session carries your branding, your title and your prices.

The Coinland Pay checkout page: the merchant name and logo at the top, the order title, a choice between paying 25.5 USDT or 0.000031 BTC with the customer's balance shown under each coin, a payment deadline counting down, and a gold pay button.
The hosted checkout page, on Coinland's origin. The customer picks one of the coins you priced the order in and confirms.

That capture shows the signed-in state. A customer who is not signed in to Coinland sees the same merchant header and order summary with a sign-in form beneath it, and lands on this screen once they authenticate. The merchant name, order number and receipt number in these captures are sample data; the layout, type and behaviour are the real page. The order title and description are the merchant's own single strings, so they appear as the merchant wrote them in either language.

The widget computes no money of its own. Every figure on it is a field it was handed, fixed when the session was created, and the total on the pay button is the same number the ledger debits. There is no client-side arithmetic that could disagree with the charge, and nothing on the page can drift between what the customer reads and what they pay.

What the customer sees depends on who carries the fee. Under fee_bearer: "merchant" they see one number — the order amount — because the fee is yours and none of it is added to their total. Under fee_bearer: "customer" the surcharge is shown as its own line, because they are being asked to pay it. Your platform rate is never on this page in either case.

The Coinland Pay confirmation screen: a green check, the heading 'payment completed', and a summary table listing the amount paid, the merchant, the receipt number CLP-7QM4K2XD and the payment time.
After the transfer settles. The receipt number shown here is the one your customer will quote to support.

Three integration levels

LevelYou writeThe customer seesUse it when
1. Hosted redirectA 302 to checkout_urlYour page, then Coinland'sYou want the least code, or you run no JavaScript
2. PopupCoinlandPay.open()Your page, with Coinland over itYou want the customer to stay on your page
3. Embedded inlineCoinlandPay.mount()The order card in your pageYou want the order visible in your own checkout

They share one script, one session and one completion payload, so moving between them is a few lines. Start at level 1 and go up only when you want what the next level adds.

Level 1: hosted redirect

No JavaScript at all. The session response carries checkout_url; send a 302 to it.

import type { Request, Response } from "express";

const API = "https://my.coinlandexchange.com";

export async function startCheckout(req: Request, res: Response) {
  const order = await loadOrder(req.params.orderId);

  const session = await fetch(`${API}/api/pay/v1/sessions`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.COINLAND_PAY_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      reference_id: order.id,
      title: `Order ${order.number}`,
      amounts: [{ currency: "usdt", amount: order.totalUsdt }],
      return_url: "https://example.com/checkout/done",
      cancel_url: "https://example.com/cart",
    }),
  }).then((r) => r.json());

  // Store the session id against the order BEFORE sending the customer away.
  await order.update({ paySessionId: session.id });

  res.redirect(302, session.checkout_url);
}

The customer pays, comes back to your return_url with ?receipt=<token>&payment_id=<id>, and your webhook handler does the fulfilment. Nothing is lost by choosing this over the other two except the customer staying on your page.

The embed script

Levels 2 and 3 need one script tag, no build step and no bundle:

<script src="https://my.coinlandexchange.com/pay/v1.js"></script>

It defines a single global, CoinlandPay, and pulls in nothing else. Load it with defer in your <head> or at the end of <body>.

Level 2: popup

CoinlandPay.open({
  sessionId: "3a7f21e8-9c04-4d6b-8e15-7b2a9f3c1d60",

  onComplete({ payment_id, receipt_no, receipt }) {
    // The customer paid. Advance your own UI, then reconcile server-side.
    window.location.href = `/checkout/done?payment_id=${payment_id}`;
  },

  onCancel() {
    // The popup was dismissed. The session is untouched and stays open until it
    // expires, so calling open() again with the same sessionId resumes it.
  },
});

Prop

Type

open() returns immediately. It does not resolve a promise on payment, because the popup may outlive the page that opened it -- a customer who completes a payment after your tab is discarded still gets their payment, and you still get the webhook.

Because open() may navigate away when a popup is blocked (see below), treat it as the last thing your handler does. Do not queue work after it that has to run.

Level 3: embedded inline

mount() renders the order card inside your page: your customer sees the branding, the amounts, the coin options and the countdown without leaving your checkout. The card is an iframe pointing at Coinland, and it is display-only -- pressing pay breaks out to the hosted page in a popup, exactly as level 2 does.

The embedded Coinland Pay card rendered inside a merchant's own page: merchant name, order title, the two coin options with their amounts, the payment deadline, a gold pay button, and a line explaining that a secure Coinland window opens to finish the payment.
The embedded card, rendered inside your checkout by mount(). It shows the order and nothing else -- pressing pay opens Coinland's hosted page.
<div id="coinland-pay"></div>

<script src="https://my.coinlandexchange.com/pay/v1.js"></script>
<script>
  CoinlandPay.mount(document.getElementById("coinland-pay"), {
    sessionId: "3a7f21e8-9c04-4d6b-8e15-7b2a9f3c1d60",

    onComplete({ payment_id, receipt_no, receipt }) {
      window.location.href = `/checkout/done?payment_id=${payment_id}`;
    },

    onCancel() {
      // The customer closed the payment window without paying. The card stays
      // mounted and the session stays open.
    },
  });
</script>

Prop

Type

Give the container a width and let the card fill it; it is responsive down to phone widths. If the session is already completed, expired or canceled when it mounts, the card renders that state instead of a pay button.

The relationship between the three levels is worth stating plainly: level 3 contains level 2. The embedded card is a display surface, and the moment the customer commits, it opens the same hosted page open() would have. If the popup is blocked, both levels fall back to the same full-page redirect, and the customer returns to your return_url with ?receipt=<token>&payment_id=<id> appended.

Your return_url must work at every level

Even if you only ever intend to use the popup or the embedded card, a customer with popups disabled arrives at return_url instead of triggering onComplete. Build that page to read payment_id, ask your own server about the order, and show a pending state if the webhook has not landed yet.

Why checkout is never fully inside your page

The embedded card is framed; the confirmation step never is. The checkout page at https://my.coinlandexchange.com/pay/{sessionId} sends X-Frame-Options: DENY and frame-ancestors 'none', so a browser refuses to render it inside your site. Only the display-only embed route that mount() loads is frameable. Three independent reasons, and each one alone would be enough:

  • A customer cannot verify a framed login. The address bar shows your domain while the form asks for Coinland credentials and a one-time code. There is no way to tell that frame apart from one you drew yourself, which is precisely the shape of a phishing page -- and it trains customers to type Coinland credentials into non-Coinland chrome. A popup keeps my.coinlandexchange.com visible in the address bar for the whole time they are authenticating, which is the one signal that actually protects them.
  • A parent page can watch a frame in ways it cannot watch a popup. Focus, keystroke timing and layout are observable from the embedder, and an overlay on top of a frame is the classic clickjacking attack: the customer believes they are clicking one thing and confirms another.
  • It would not work anyway. Browsers now partition third-party cookies by the embedding site, so a Coinland session inside your page is not the Coinland session the customer already has. They would be asked to log in again, in the least trustworthy possible place, every single time.

The embed card is safe to frame precisely because it cannot move money. It displays a session that is already public to whoever holds its id, it takes no credential, and it has no authority to confirm anything. Everything that requires the customer to prove who they are happens on Coinland's origin, in a window whose URL they can read.

Handling the result

Whichever level you use, the result arrives the same way.

onComplete is a UI callback, not a settlement signal

It runs in the customer's browser and can be triggered by anyone with a console open. Use it to advance your own UI. Fulfil the order from the webhook or from GET /payments/{id}, which are the only two things a customer cannot influence.

The ids can be empty, so never render them directly

There is a slow fallback path — the customer closed the payment window before it reported back — where onComplete fires with payment_id, receipt_no and receipt all empty strings. Only the fact of completion is known. A handler that puts receipt_no straight on the page shows a blank where the receipt number should be, and nothing throws to tell you.

So treat the callback as "something finished, go and ask": look the payment up by your own reference_id with GET /api/pay/v1/sessions/{reference_id} server-side, and render from that. This is the same discipline the callback already demands for settlement — it just also applies to the fields.

Internally the popup reports back with postMessage to window.opener, and the embedded card relays the same message from its frame. The body is { source: "coinland-pay", type, sessionId, ... }, where type is payment_completed or checkout_canceled. If you are handling messages yourself instead of using the script -- which you should not need to -- the only rule that matters is the origin check:

window.addEventListener("message", (event) => {
  // Never skip this. Without it, any page in any tab can post you a fake
  // "payment_completed" and drive your UI.
  if (event.origin !== "https://my.coinlandexchange.com") return;
  if (event.data?.source !== "coinland-pay") return;
  // ...
});

The script also watches whether the payment window has been closed. A customer who closes it without paying produces no message at all, so a closed-window poll is what turns that into onCancel.

Styling and branding

You cannot style the widget, because you cannot reach into it. What you can change lives in your business console: display name, Persian display name and logo, all of which render above the order title. The session's title and description are yours per order.

The widget is Persian and RTL by default for the Iranian market, and follows the customer's own language preference on Coinland rather than your page's.

Checklist

  • The script tag points at https://my.coinlandexchange.com/pay/v1.js, not a copy you host.
  • return_url and cancel_url are https and both work when visited directly.
  • Your return_url page tolerates arriving before the webhook does.
  • Nothing in onComplete grants access on its own.
  • You never try to iframe the checkout page itself -- only mount() frames anything, and it frames the display card.

On this page