Quickstart
From a promoted account to a fulfilled order, with the requests written out
This is the whole integration end to end. Five steps, no SDK required.
1. Get promoted to a business
There is no separate business registration and no special sign-up flow. Coinland Pay runs on an ordinary Coinland account, and you get access in three steps:
Sign up as a normal customer at my.coinlandexchange.com/register, through the ordinary registration flow. Nothing about it is business-specific.
Open a support ticket from your dashboard's support section, asking for a business account and access to Coinland Pay. Include your business details.
The team reviews and replies in that same ticket. Once approved, your account is upgraded and a business management section appears in your dashboard -- that is where you mint API keys and configure the widget.
There is no application form and no automatic self-serve activation: a person reads your ticket.
Once promoted, the business console appears in your dashboard at
https://my.coinlandexchange.com/business. That is where you set:
- Accepted coins. Only these may appear in a session's
amounts. Anything else is refused withPAY_CURRENCY_NOT_ACCEPTED. Coinland Pay supports cryptocurrencies only; Toman (IRT) payments are not offered on this rail. - Display name and logo. What the customer sees in the widget above your order title.
- Your webhook URL (https only). Saving it shows your webhook signing secret in the same step -- that is the value your verifier needs, and you can open it again later from the same card.
- Your receipt signing secret, a separate key that signs receipt tokens. It sits in its own card because rotating it is retroactive while rotating the webhook one is not.
- Session lifetime, the default TTL for a checkout session.
- Auto-convert, off by default: have every coin you receive sold into USDT so you hold a single asset. It is a live market trade, not a free conversion — see Payments.
Completed payments land in a dedicated business wallet, separate from your personal spot and trading balance. To spend or withdraw your takings you transfer them from the business wallet to your main wallet — instantly and for free — and then trade or withdraw as you always have. See Payments.
2. Mint an API key
In the business console, create a key. It looks like this:
clpay_live_4f9d2c8a1b7e6f3d0a5c9b2e8f1a6d4c7b0e3f9a2c5d8b1e4f7a0c3d6b9e2f5aShown once
The full key is displayed only at creation. Coinland stores a hash of it, never the value, so a lost key cannot be recovered -- revoke it and mint another. Only the prefix is kept, so you can tell your keys apart in the console.
Store it the way you store a database password: in your server's secret manager, never in a repository, a browser bundle, or a mobile app binary. If your server has a fixed outbound address, you can later pin the key to it. Every request carries it as a bearer token:
Authorization: Bearer clpay_live_<64 hex>Confirm the key works, and see what you are allowed to price in, with one call:
curl https://my.coinlandexchange.com/api/pay/v1/me \
-H "Authorization: Bearer $COINLAND_PAY_KEY"{
"id": "8f1c9a34-3d2e-4b17-9f0a-2c6d5b8e4a71",
"display_name": "Example Store",
"display_name_fa": "فروشگاه نمونه",
"logo_url": "https://my.coinlandexchange.com/media/merchants/8f1c9a34.png",
"status": "active",
"accepted_currencies": ["usdt", "btc"],
"webhook_url": "https://example.com/webhooks/coinland"
}Read accepted_currencies at startup rather than hard-coding a coin list: it changes in the console
without a deploy on your side.
3. Create a checkout session
One call per order, from your server, at the moment the customer chooses to pay.
const API = "https://my.coinlandexchange.com";
const apiKey = process.env.COINLAND_PAY_KEY!; // clpay_live_...
interface Amount {
currency: string;
amount: string; // decimal STRING, never a float
}
interface Customer {
id: string; // YOUR internal user id -- the binding is keyed on it forever
name: string; // full name, Latin script
native_name?: string; // full name in the customer's own script -- send it
email: string; // the customer's email at YOUR service
}
export async function createSession(order: {
id: string;
number: string;
amounts: Amount[];
customer: Customer;
}) {
const res = await fetch(`${API}/api/pay/v1/sessions`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
reference_id: order.id, // your order id, and your idempotency key
title: `Order ${order.number}`,
amounts: order.amounts,
customer: order.customer, // REQUIRED -- who this payment belongs to
return_url: "https://example.com/checkout/done",
cancel_url: "https://example.com/cart",
}),
});
if (!res.ok) {
const body = await res.json().catch(() => null);
const code = body?.errors?.error?.[0] ?? "UNKNOWN";
throw new Error(`coinland pay ${res.status}: ${code}`);
}
return res.json();
}The customer block is mandatory -- your backend must identify your customer
Every create call names the customer the payment belongs to, and there is no opt-out. Your backend
MUST send at least three identifiers: customer.id (your own internal user id for this person,
[A-Za-z0-9_.:-], 1-128 characters), customer.name (their full name in Latin script) and
customer.email (their email address with you, which may differ from their Coinland email).
Send customer.native_name too whenever you hold it -- it is the strong basis for the name
check. A create call without the block is refused with PAY_CUSTOMER_REQUIRED (422).
This is an AML requirement, and it is permanent: the first time that customer pays, their Coinland
account is bound to your customer.id for good. From then on only that account can pay sessions
for that customer, and only you can revoke the link. The identity is also part of the idempotency
fingerprint -- replaying a reference_id with a different customer is PAY_DUPLICATE_REFERENCE.
See Customer identity binding.
{
"id": "3a7f21e8-9c04-4d6b-8e15-7b2a9f3c1d60",
"reference_id": "order-10492",
"status": "open",
"title": "Order 10492",
"description": "2 items",
"amounts": [
{ "currency": "usdt", "amount": "24.90" },
{ "currency": "btc", "amount": "0.00027" }
],
"checkout_url": "https://my.coinlandexchange.com/pay/3a7f21e8-9c04-4d6b-8e15-7b2a9f3c1d60",
"return_url": "https://example.com/checkout/done",
"cancel_url": "https://example.com/cart",
"metadata": { "cart_id": "c_88213" },
"payment": null,
"expires_at": "2026-08-11T12:30:00Z",
"created_at": "2026-08-11T12:00:00Z"
}Three things worth getting right the first time:
- Amounts are decimal strings.
"24.90", not24.9. A float cannot hold every decimal amount exactly, and a rounding error in a price is a rounding error in what you get paid. reference_idis your idempotency key. Retrying with the same id and the same payload returns the original session instead of creating a second one. See Sessions.- Store
session.idagainst your order before you send the customer anywhere. It is how you reconcile the webhook later.
4. Send the customer to the widget
Either open the widget in a popup over your own page, which is the better experience:
<script src="https://my.coinlandexchange.com/pay/v1.js"></script>
<button id="pay">Pay with Coinland</button>
<script>
document.getElementById("pay").addEventListener("click", () => {
CoinlandPay.open({
sessionId: "3a7f21e8-9c04-4d6b-8e15-7b2a9f3c1d60",
onComplete({ payment_id }) {
window.location.href = `/checkout/done?payment_id=${payment_id}`;
},
onCancel() {
// The customer closed the widget. The session is still open until it
// expires, so the same checkout_url keeps working.
},
});
});
</script>Or redirect, with no JavaScript at all:
302 Location: https://my.coinlandexchange.com/pay/3a7f21e8-9c04-4d6b-8e15-7b2a9f3c1d60After paying, the customer lands on your return_url with ?receipt=<token>&payment_id=<id>
appended. Full detail, including the popup-blocked fallback, is in Widget.
The return_url is not the payment confirmation
A redirect is something the browser does. It can be lost to a closed tab, a flaky network or a crashed phone, and it can be typed by hand by someone who never paid. Treat the landing page as a cue to show a spinner and poll, not as proof. Fulfil in step 5.
5. Receive the webhook and fulfil
Coinland POSTs to your registered URL, signed with your webhook signing secret. Note that the
x-pay-signature header can carry more than one v1= value during a
secret rotation, so verify against every one of them:
{
"event_id": "d41f8c62-5a10-4e93-b7d8-0c2a5f6e1b34",
"type": "payment.completed",
"session_id": "3a7f21e8-9c04-4d6b-8e15-7b2a9f3c1d60",
"reference_id": "order-10492",
"payment_id": "b92e4d17-6c38-4a05-9f2b-1e7d3c8a5049",
"status": "completed"
}Verify the signature, dedupe on event_id, then read the authoritative record and fulfil:
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.COINLAND_PAY_WEBHOOK_SECRET;
// The RAW body is what was signed. Any JSON parser that re-serialises it will
// break the comparison, so parse after verifying, not before.
app.post(
"/webhooks/coinland",
express.raw({ type: "application/json" }),
async (req, res) => {
const header = req.get("x-pay-signature") ?? "";
// NOT Object.fromEntries: a map keeps one value per key, and during a
// secret rotation this header carries TWO v1 signatures. Collect them all
// and accept the request if ANY of them matches.
let t;
const signatures = [];
for (const part of header.split(",")) {
const i = part.indexOf("=");
if (i <= 0) continue;
const name = part.slice(0, i).trim();
const value = part.slice(i + 1).trim();
if (name === "t") t = t ?? value; // first t wins
else if (name === "v1") signatures.push(value);
}
const age = Math.abs(Date.now() / 1000 - Number(t));
if (!Number.isFinite(age) || age > 300) return res.sendStatus(400);
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${t}.${req.body.toString("utf8")}`)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const ok = signatures.some((candidate) => {
const b = Buffer.from(candidate, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
if (!ok) return res.sendStatus(400);
// Signature is good. Acknowledge fast, then work: a slow handler is a
// retried handler.
res.sendStatus(200);
try {
const event = JSON.parse(req.body.toString("utf8"));
if (await alreadyHandled(event.event_id)) return;
if (event.type !== "payment.completed") return;
// The payload is a HINT. This is the authority.
const payment = await fetch(
`https://my.coinlandexchange.com/api/pay/v1/payments/${event.payment_id}`,
{ headers: { Authorization: `Bearer ${process.env.COINLAND_PAY_KEY}` } },
).then((r) => r.json());
await fulfilOrder(payment.reference_id, {
currency: payment.currency,
amount: payment.amount, // what the customer paid
net: payment.net_amount, // what you were credited
receiptNo: payment.receipt_no,
});
await markHandled(event.event_id);
} catch (err) {
// You already answered 200, so there is no retry from Coinland.
// Log it and let your daily reconciliation sweep pick the order up.
console.error("coinland pay fulfilment failed", err);
}
},
);That is a complete integration. Webhooks covers retries, the session.expired event and
what a non-2xx from you causes.
What to check before you ship
- Your webhook URL is https and reachable from the public internet.
- Your handler answers 2xx within a few seconds and does its work afterwards.
- You dedupe on
event_id, because a redelivery is normal and not an error. - You fulfil on the webhook or the API, never on
return_urlalone. - Your key lives server-side only.
- Every session carries your customer's real identity -- their real id in your system, real name,
real email -- never a placeholder, because the first payment binds the payer's Coinland account to
that
customer.idpermanently. - You handle
binding.review: a customer whose name check failed cannot pay until you approve or reject the binding. See Customer identity binding.
The full list is in Going live.