# خطاها (/fa/errors)
هر پاسخ غیر ۲xx از کوینلند پی همین شکل را دارد:
```json
{
"statusCode": 422,
"errors": {
"error": ["PAY_CURRENCY_NOT_ACCEPTED"]
}
}
```
`errors.error` آرایهای از **کدهای ماشینیِ پایدار** با قالب `UPPER_SNAKE` است. هیچ پیام خوانا، در هیچ
زبانی، در هیچ جای پاسخ وجود ندارد — و این عامدانه است: مشتریان شما متن شما را میخوانند، نه متن ما. کد را
به جملهای که خودتان نوشتهاید نگاشت کنید، به زبانی که مشتری شما صحبت میکند.
کدها فقط افزوده میشوند. کدی که منتشر شود هرگز معنایش تغییر نمیکند و هرگز تغییر نام نمیدهد، چون ترجمهها و
هشدارهای شما بر پایه آنها ساخته شدهاند. کدهای تازه میتوانند ظاهر شوند، پس برای کدهایی که مدیریت میکنید
شاخه بگذارید و بقیه را به یک پیام عمومی بسپارید.
## خواندن قالب خطا [#خواندن-قالب-خطا]
```js
const res = await fetch(url, init);
if (!res.ok) {
const body = await res.json().catch(() => null);
const code = body?.errors?.error?.[0] ?? "UNKNOWN";
throw new CoinlandPayError(code, res.status);
}
```
دو نکته که ارزش دارد بر پایهشان بسازید:
* **آرایه میتواند بیش از یک کد داشته باشد.** خطاهای اعتبارسنجی ممکن است چند کد را یکجا گزارش کنند. اولی را
برای شاخهبندی بگیرید و همه را ثبت کنید.
* **تنها بر پایه کد وضعیت شاخه نگذارید.** چند کد یک وضعیت مشترک دارند؛ کد قرارداد است و وضعیت جزئیات
انتقال.
## کدهای کوینلند پی [#کدهای-کوینلند-پی]
### جلسههای پرداخت و پرداختها [#جلسههای-پرداخت-و-پرداختها]
| کد | HTTP | معنا | چه کاری کنید |
| --------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- |
| `PAY_SESSION_NOT_FOUND` | ۴۰۴ | جلسه یا پرداختی با آن شناسه زیر حساب شما نیست | شناسه را بررسی کنید. جلسهای که به کسبوکار دیگری تعلق دارد هم عامدانه همین پاسخ را میگیرد |
| `PAY_SESSION_EXPIRED` | ۴۲۲ | مهلت جلسه پیش از تأیید مشتری گذشت | جلسه تازه بسازید؛ چیزی رزرو نشده بود |
| `PAY_SESSION_STATE` | ۴۰۹ | این عمل در وضعیت فعلی جلسه مجاز نیست، مثلاً لغو یک جلسه تکمیلشده | جلسه را بخوانید و بر پایه `status` شاخه بگذارید |
| `PAY_DUPLICATE_REFERENCE` | ۴۰۹ | همان `reference_id` با محتوای **متفاوت** فرستاده شد | شناسه تازه نسازید. یا همان محتوای اصلی را بفرستید، یا این را یک سفارش تازه بگیرید |
| `PAY_CURRENCY_NOT_ACCEPTED` | ۴۲۲ | ارزی در `amounts` در مجموعه پذیرفتهشده شما نیست، در کل پلتفرم غیرفعال است، یا تومان است — این ریل فقط رمزارز است | `accepted_currencies` را در زمان راهاندازی از `GET /me` بخوانید و ارزها را در کد ثابت نکنید |
| `PAY_AMOUNT_INVALID` | ۴۲۲ | غیرمثبت، ارقام اعشار بیش از حد آن ارز، یا بیرون از محدودههای پلتفرم — همچنین وقتی درخواست ساخت جلسه هم `amounts` و هم `price_usd` را بفرستد، یا هیچکدام را | مبالغ را بهصورت رشته اعشاری و با دقت خودِ آن ارز قالببندی کنید، و دقیقاً یکی از `amounts` یا `price_usd` را بفرستید |
| `PAY_RATE_UNAVAILABLE` | ۵۰۳ | `price_usd` فرستاده شده اما همین حالا هیچ ارز پذیرفتهشدهای قابل قیمتگذاری نیست | دوباره تلاش کنید؛ خودِ درخواست مشکلی ندارد |
| `PAY_SELF_PAYMENT` | ۴۲۲ | حسابی که میخواهد پرداخت کند همان کسبوکاری است که جلسه را ساخته | چیزی در کد شما برای اصلاح نیست؛ یک کسبوکار نمیتواند به خودش پرداخت کند |
| `PAY_RECEIPT_INVALID` | ۴۲۲ | اعتبارسنجی رسید ناموفق بود: امضای نادرست، محتوای تغییریافته، یا پرداخت ناشناس | رسید را نامعتبر بگیرید. یک کد هر سه را پوشش میدهد تا تلاش جعل چیزی یاد نگیرد |
ارسال دوباره و عیناً یکسانِ `POST /sessions` خطا **نیست**: همان `reference_id` با همان محتوا، جلسه اصلی را
با وضعیت موفق برمیگرداند. تنها محتوای تغییریافته `PAY_DUPLICATE_REFERENCE` را ایجاد میکند.
[جلسههای پرداخت](/concepts/sessions) را ببینید.
### پرداخت به مشتری و بازپرداخت [#پرداخت-به-مشتری-و-بازپرداخت]
اینها از [جهت پرداخت به مشتری](/payouts) میآیند و به کلیدی از کلاس پرداخت به مشتری نیاز دارند.
| کد | HTTP | معنا | چه کاری کنید |
| ---------------------------- | ---- | ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `MERCHANT_PAYOUTS_DISABLED` | ۴۰۳ | پرداخت به مشتری برای کسبوکار شما مسلح نیست، یا مسیر جستوجوی ایمیل برایتان فعال نشده | با کوینلند تماس بگیرید. یک کد، هم ریل غیرفعال و هم کسبوکار معلق و هم روشننبودن پرداخت و هم تنظیمنشدن سقفها را پوشش میدهد — راهحل یکی است |
| `PAY_WRONG_KEY_KIND` | ۴۰۳ | کلید پرداختگیری روی مسیر پرداخت به مشتری به کار رفته، یا برعکس | از کلید کلاس دیگر استفاده کنید. خودِ کلید معتبر است و به همین دلیل این خطا `UNAUTHORIZED` نیست |
| `PAY_RECIPIENT_INVALID` | ۴۲۲ | گیرنده قابل پرداخت نیست، یا درخواست هر دو مسیر گیرنده را فرستاده، یا هیچکدام | `payer_id` را بررسی کنید یا جستوجو را تکرار کنید. یک کد، نشانی ناشناس و حساب غیرواجد شرایط و توکن منقضی یا متعلق به کسبوکار دیگر و `recipient_confirm` ناهمخوان را پوشش میدهد تا نشود با این اندپوینت فهمید چه کسی حساب دارد |
| `PAY_PAYOUT_LIMIT` | ۴۲۲ | عبور از بیشینه هر پرداخت یا سقف ۲۴ ساعت گذشته | پرداخت را در چند روز بشکنید، منتظر پایان بازه بمانید، یا از کوینلند افزایش سقف بخواهید |
| `PAY_REFUND_EXCEEDS_PAYMENT` | ۴۲۲ | جمع بازپرداختها از `charged_amount` آن پرداخت عبور میکند | جمع بازپرداختهای خودتان از آن پرداخت را با `charged_amount` مقایسه کنید و فقط باقیمانده را بازپرداخت کنید |
| `PAY_LOOKUP_THROTTLED` | ۴۲۹ | سهمیه جستوجوی گیرنده در آن دقیقه یا آن روز تمام شده است | روی جستوجو عقب بکشید. پرداخت با `payer_id` تأثیری نمیگیرد |
کدهای `INSUFFICIENT_BALANCE` (۴۲۲) و `PAY_RATE_UNAVAILABLE` (۵۰۳) هم روی این سطح دیده میشوند: اولی وقتی
کیفپول کسبوکارتان `amount + fee` را پوشش نمیدهد، و دومی وقتی ارز نرخ زنده دلاری ندارد و در نتیجه سقفها
قابل اعمال نیستند. پرداخت رد میشود، نه اینکه بدون سنجش برود.
### حساب کسبوکار شما [#حساب-کسبوکار-شما]
| کد | HTTP | معنا | چه کاری کنید |
| --------------------------------- | ---- | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `MERCHANT_NOT_FOUND` | ۴۰۴ | حسابی که این کلید به آن تعلق دارد یک کسبوکار نیست | از پشتیبانی کوینلند ارتقای حساب را بخواهید |
| `MERCHANT_DISABLED` | ۴۰۳ | این کسبوکار در حال حاضر نمیتواند پرداخت بگیرد | با کوینلند تماس بگیرید. یک کد هم کسبوکار معلق و هم ریل غیرفعال در کل پلتفرم را پوشش میدهد؛ تفکیک آنها فقط برای اپراتور است، چون راهحل یکی است |
| `MERCHANT_KEY_LIMIT` | ۴۲۲ | سقف کلیدهای API فعال پر شده است | پیش از ساخت کلید تازه، یکی را در کنسول کسبوکار باطل کنید |
| `MERCHANT_WEBHOOK_URL_INVALID` | ۴۲۲ | https نیست، غیرقابلتحلیل است، یا به یک آدرس خصوصی اشاره میکند | از یک نشانی https قابل دسترسی از اینترنت عمومی استفاده کنید |
| `MERCHANT_LOGO_INVALID` | ۴۲۲ | لوگوی بارگذاریشده بیرون از فهرست مجاز نوع یا اندازه است | محدودیتهای نمایشدادهشده در کنسول را ببینید |
| `MERCHANT_CONVERT_TARGET_INVALID` | ۴۲۲ | ارز مقصد تبدیل خودکار در فهرست مقصدهای مجاز کوینلند نیست | یکی از مقصدهایی را که کنسول پیشنهاد میدهد انتخاب کنید |
| `MERCHANT_EXISTS` | ۴۰۹ | ارتقا روی حسابی انجام شد که پیش از این کسبوکار بوده | کاری لازم نیست؛ حساب شما ارتقا یافته است |
پنج مورد آخر از کنسول کسبوکار میآیند، نه از API کسبوکار. اینجا فهرست شدهاند چون کنسول و API یک قالب خطا
و یک فهرست کد مشترک دارند، پس کدی که در مرورگر میبینید همان معنایی را دارد که روی API داشت.
یعنی کوینلند پرداختهای شما را خاموش کرده است، یا مخصوص کسبوکار شما یا در کل پلتفرم. تلاش مجدد آن را
برطرف نمیکند. برای مشتریانتان یک حالت «موقتاً در دسترس نیست» نشان دهید و با ما تماس بگیرید.
## کدهای عمومی [#کدهای-عمومی]
اینها در سطح پلتفرم هستند و هر اندپوینتی میتواند برگرداندشان.
| کد | HTTP | معنا |
| ----------------------- | ---- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `UNAUTHORIZED` | ۴۰۱ | کلید API غایب، بدشکل، باطلشده یا ناشناس. یک کد برای هر رد شدن در لایه احراز هویت، تا فراخواننده نتواند بفهمد کدام بخش اشتباه بوده |
| `FORBIDDEN` | ۴۰۳ | احراز هویت شده، اما مجاز نیست |
| `VALIDATION_FAILED` | ۴۲۲ | بدنه درخواست از اعتبارسنجی طرحواره رد شد: فیلد الزامی جاافتاده، نوع نادرست، رشته بیش از حد بلند |
| `RATE_LIMITED` | ۴۲۹ | درخواست بیش از حد. پیش از تلاش مجدد با تأخیر تصادفی عقب بکشید |
| `NOT_FOUND` | ۴۰۴ | چنین مسیری وجود ندارد |
| `CONFLICT` | ۴۰۹ | تضاد وضعیت عمومی، جایی که کد مشخصتری کاربرد ندارد |
| `INTERNAL_SERVER_ERROR` | ۵۰۰ | چیزی در سمت ما شکست خورد |
| `SERVICE_UNAVAILABLE` | ۵۰۳ | موقتاً قادر به پاسخدهی نیست. با عقبنشینی تلاش کنید |
| `MAINTENANCE_MODE` | ۵۰۳ | کوینلند در حالت تعمیرات است. خواندن و نوشتن هر دو خاموشاند |
## کدام خطاها را دوباره تلاش کنیم [#کدام-خطاها-را-دوباره-تلاش-کنیم]
| وضعیت | تلاش مجدد؟ |
| ----------------------- | -------------------------------------------------------------------- |
| وقفه زمانی یا قطع اتصال | **بله**، با همان `reference_id`. ایدمپوتنسی برای همین است |
| `429 RATE_LIMITED` | بله، پس از عقبنشینی با تأخیر تصادفی |
| `500`، `502`، `503` | بله، با عقبنشینی نمایی |
| هر ۴xx دیگر | **نه.** درخواست نادرست است؛ تلاش مجدد بدون تغییر همان پاسخ را میدهد |
به شما نمیگوید جلسه ساخته شده یا نه. همان درخواست را عیناً و با همان `reference_id` دوباره بفرستید: اگر
انجام شده بود جلسه اصلی را میگیرید و اگر نشده بود یک جلسه تازه. ساختن یک شناسه تازه بهجای آن، همان
کاری است که یک سفارش را به دو جلسه پرداخت میرساند.
## نگاشت کدها به متن خودتان [#نگاشت-کدها-به-متن-خودتان]
نگاشت را در یک جا و بر پایه کد نگه دارید، با یک حالت پیشفرض:
```js
const MESSAGES = {
PAY_SESSION_EXPIRED: "این پرداخت منقضی شده است. برای گرفتن یک پرداخت تازه دوباره شروع کنید.",
PAY_CURRENCY_NOT_ACCEPTED: "در حال حاضر نمیتوانیم این ارز را بپذیریم.",
PAY_AMOUNT_INVALID: "مبلغ سفارش مشکلی دارد.",
MERCHANT_DISABLED: "پرداخت با کوینلند در حال حاضر در دسترس نیست.",
RATE_LIMITED: "تلاشهای بیش از حد. یک دقیقه دیگر امتحان کنید.",
};
// کدی که هرگز ندیدهاید هم باید یک جمله تولید کند، چون کدهای تازه بدون تغییر
// نسخه منتشر میشوند.
export const messageFor = (code) => MESSAGES[code] ?? "پرداخت انجام نشد.";
```
هر چیزی که به مشتری نشان میدهید، کد خام را در کنار شناسه درخواست خودتان ثبت کنید. وقتی با پشتیبانی
کوینلند تماس میگیرید، کد بههمراه شناسه جلسه یا پرداخت برای پیدا کردن دقیق آن رویداد کافی است.
# آماده انتشار (/fa/go-live)
کوینلند پی از همان فراخوانی اول پول واقعی جابهجا میکند. نه کلید آزمایشی وجود دارد و نه حالت تست: جلسهای
که میسازید جلسهای است که یک مشتری میتواند پرداختش کند، پس راه تمرین این است که با حساب خودتان و مبلغی
کوچک از ارزی که دارید امتحان کنید.
همین باعث میشود فهرست پایین ارزش داشته باشد که واقعاً طی شود، نه اینکه از رویش رد شوید.
## مراقبت از کلیدها [#مراقبت-از-کلیدها]
کلید API شما یک اطلاعات محرمانه از نوع bearer است. هر کسی آن را داشته باشد میتواند به نام شما جلسه پرداخت
بسازد، هر پرداختی که تا امروز گرفتهاید را بخواند، و شناسه سفارشهای مشتریانتان را ببیند.
دو کلاس کلید وجود دارد و دو اعتبارنامه جدا هستند: `clpay_live_` برای پرداختگیری و `clpay_payout_` برای
[پرداخت به مشتری و بازپرداخت](/payouts). هر چه پایین میآید برای هر دو صدق میکند، و این جدایی یک چیز
ارزشمند به شما میدهد — کلیدی که در بیشترین جاها کپی میشود نمیتواند از کیفپول شما پول بیرون بفرستد.
* **فقط سمت سرور.** هرگز در باندل مرورگر، فایل اجرایی اپلیکیشن موبایل، مخزن عمومی، لاگ CI یا تیکت
پشتیبانی. اگر کلیدی حتی یک بار در هر یک از اینها بوده، لو رفته است، مستقل از آنچه بعد از آن رخ داده.
* **فقط یک بار نمایش داده میشود.** کوینلند هش را ذخیره میکند نه مقدار را، پس کلید گمشده قابل بازیابی
نیست. فقط پیشوند نگه داشته میشود تا در کنسول کلیدها را از هم تشخیص دهید.
* **چرخاندن با همپوشانی.** کلید تازه را بسازید، منتشر کنید، مطمئن شوید ترافیک روی آن جاری است، و بعد کلید
قدیمی را باطل کنید. باطل کردن اول یعنی یک قطعی بین دو انتشار.
* **یک کلید برای هر محیط.** کلیدهای جدا برای استیجینگ و تولید یعنی میتوانید یکی را باطل کنید بدون اینکه
دیگری را لمس کنید، و پیشوند در لاگهایتان میگوید کدام سیستم آن فراخوانی را انجام داده است.
* **بر پایه گمان باطل کنید، نه بر پایه قطعیت.** باطل کردن فوری است و ساختن جانشین چند ثانیه طول میکشد.
هیچ حالتی وجود ندارد که انتظار برای قطعیت انتخاب بهتری باشد.
**کلید مخفی وبهوک** شما اطلاعات محرمانه دومی با کاری متفاوت است: آنچه ما میفرستیم را اعتبارسنجی میکند و
[توکنهای رسید](/concepts/receipts) شما را امضا میکند. همان قواعد، بهعلاوه یک مورد: چرخاندن آن، امضای
رسیدهای صادرشده با کلید قبلی را بیاعتبار میکند، پس مقدار پیشین را تا زمانی نگه دارید که توکنهایی که
مشتریانتان در دست دارند قابل اعتبارسنجی بمانند.
## همه جا HTTPS [#همه-جا-https]
سه نشانی از شما گرفته میشود و هر سه باید https باشند:
| نشانی | کجا | چرا |
| ------------ | -------------- | ------------------------------------------------------------------------------------------------------------ |
| نشانی وبهوک | کنسول کسبوکار | وبهوک بدون رمزنگاری با `MERCHANT_WEBHOOK_URL_INVALID` رد میشود. شناسه پرداخت را روی اینترنت باز حمل میکند |
| `return_url` | برای هر جلسه | یک توکن رسید را در رشته پرسوجو حمل میکند |
| `cancel_url` | برای هر جلسه | برای یکدستی، و صفحهای است که مشتری از کوینلند روی آن فرود میآید |
نشانی وبهوک شما باید از اینترنت عمومی هم قابل دسترسی باشد. ما نمیتوانیم به `localhost`، یک آدرس خصوصی،
یا هر چیزی پشت VPN شما ارسال کنیم. برای توسعه محلی از یک تونل استفاده کنید و کنسول را به نشانی https عمومی
آن تونل بدهید.
## مدیریت مبالغ [#money-handling]
هر مبلغ در این API یک رشته اعشاری است و باید تا رسیدن به یک نوع اعشاری دقیق رشته بماند.
`parseFloat("24.90")` عددی است که نمیتواند ۲۴٫۹۰ را دقیق نمایش دهد، و خطا از همان لحظهای که مبالغ یک روز
را جمع میزنید انباشته میشود.
* از نوع اعشاری زبان خودتان استفاده کنید: `BigDecimal`، `decimal.Decimal`، `Decimal` از یک کتابخانه، یا
یک عدد صحیح از کوچکترین واحد آن ارز.
* `amount`، `fee_amount` و `net_amount` را بهصورت رشته یا نوع اعشاری در پایگاه دادهتان ذخیره کنید. یک ستون
`float` یک باگ مغایرتگیری آهسته است.
* مبالغ سفارش را با `amount` و حسابهای خودتان را با `net_amount` مغایرتگیری کنید. این دو به اندازه کارمزد
کوینلند تفاوت دارند، و استفاده از یکی برای هر دو همان چیزی است که دفتر حساب را دچار انحراف میکند.
[پرداختها](/concepts/payments) را ببینید.
* مبالغ را با مقایسه اعشاری بسنجید، هرگز با `==` روی عددهای پارسشده.
## پیش از اولین پرداخت واقعی [#پیش-از-اولین-پرداخت-واقعی]
**کلید را بررسی کنید.** `GET /api/pay/v1/me` کد ۲۰۰ برمیگرداند و `accepted_currencies` هر ارزی را دارد که
قصد قیمتگذاری با آن را دارید. کد شما آن فهرست را میخواند و ارزها را ثابت نکرده است.
**یک جلسه بسازید و بخوانید.** `POST /sessions` کد ۲۰۱ برمیگرداند و
`GET /sessions/{reference_id}` آن را با شناسه خودتان پیدا میکند. هر دو مبلغ رشته اعشاری هستند.
**ایدمپوتنسی را اثبات کنید.** همان `POST /sessions` را دو بار بفرستید. همان شناسه جلسه را میگیرید، نه دو
جلسه. بعد بار سوم با مبلغی تغییریافته بفرستید و تأیید کنید که `PAY_DUPLICATE_REFERENCE` میگیرید.
**خودتان یکی را پرداخت کنید.** با مبلغی کوچک از ارزی که دارید. تأیید کنید ویجت باز میشود، پرداخت تسویه
میشود، و موجودی شما به اندازه `net_amount` تغییر میکند.
**تأیید کنید وبهوک میرسد و اعتبارسنجی میشود.** هندلر شما امضا را بررسی میکند، پنجره پنجدقیقهای را اعمال
میکند، در زمان ثابت مقایسه میکند، و درخواستی را که بدنهاش را عمداً دستکاری کردهاید رد میکند.
**حذف تکراریها را اثبات کنید.** همان ارسال را دوباره به اندپوینت خودتان بفرستید. سفارش باید یک بار تحویل
شود. سازوکار، یک قید یگانگی روی `event_id` است؛ یک `SELECT` و بعد `INSERT` رقابتی دارد که دو تلاش همزمان
پیدایش میکنند.
**مسیر هدایت را آزمایش کنید.** پنجرههای بازشو را در مرورگرتان مسدود کنید و دوباره پرداخت کنید. صفحه
`return_url` شما باید تحمل کند که پیش از رسیدن وبهوک باز شود و بهجای خطا حالت «در انتظار» نشان دهد.
**لغو و انقضا را آزمایش کنید.** یک جلسه را لغو کنید و تأیید کنید سفارشتان بسته میشود. بگذارید یکی منقضی
شود و تأیید کنید رویداد `session.expired` سبد را آزاد میکند.
**یک رسید را آفلاین اعتبارسنجی کنید.** توکن پرداخت آزمایشیتان را بردارید، با کلید مخفی وبهوک بررسی کنید،
بعد یک کاراکتر از محتوا را عوض کنید و تأیید کنید کد اعتبارسنجی شما آن را رد میکند.
**پرداخت را بازخوانی کنید.** `GET /payments/{receipt_no}` آن را با شماره رسید پیدا میکند و مبالغ با آنچه
مطالبه کرده بودید یکی است.
## پیش از اولین پرداخت به مشتری [#before-first-payout]
فقط اگر پول به بیرون میفرستید. [پرداخت به مشتری](/payouts) یک مرحله مسلحسازی جداگانه است و بخشی از
راهاندازی پرداختگیری شما نیست.
**تأیید کنید مسلح هستید.** کوینلند پرداخت به مشتری را برای کسبوکار شما فعال کرده **و** هر دو سقف دلاری
را تنظیم کرده باشد. تا وقتی هر دو برقرار نباشند، هر پرداختی `MERCHANT_PAYOUTS_DISABLED` میگیرد — سقفِ
تنظیمنشده یعنی «همه چیز رد میشود»، نه «بینهایت».
**مبلغ کوچکی تسویه کنید** با `payer_id` — هندلی که از خرید آزمایشیِ یک حساب آزمایشی دوم ساخته شده
(پرداخت به حساب کسبوکار خودتان بهعنوان تسویه به خود رد میشود). تأیید کنید آن حساب دقیقاً
`amount` را میگیرد و کیفپول کسبوکارتان به اندازه `debited_amount` کم میشود.
**ایدمپوتنسی پرداخت را اثبات کنید.** همان `POST /payouts` را دو بار بفرستید. بار دوم با کد ۲۰۱ و همان
شناسه پرداخت پاسخ میدهد و هیچ پولی جابهجا نمیکند. این مهمترین چیزی است که روی این ریل باید بررسی
کنید، چون حالت خرابیاش این است که به کسی دو بار پول بدهید.
**یک پرداخت را جزئی بازپرداخت کنید.** بعد باقیمانده را بازپرداخت کنید، بعد یکی دیگر امتحان کنید و تأیید
کنید `PAY_REFUND_EXCEEDS_PAYMENT` میگیرید. بررسی کنید که `fee_amount` در هر دو `"0"` بوده است.
**سقف خودتان را عمداً رد کنید.** پرداختی بالاتر از بیشینه هر پرداخت امتحان کنید و تأیید کنید کد شما
`PAY_PAYOUT_LIMIT` را به یک انسان نشان میدهد، نه اینکه مثل یک خطای گذرا دوباره تلاش کند.
## عادتهای عملیاتی [#عادتهای-عملیاتی]
**فقط به وبهوک تکیه نکنید.** وبهوک مسیر اصلی است، نه تنها مسیر. دو عادت ارزان، یکپارچهسازی را در برابر
ارسالی که هرگز نمیرسد مقاوم میکند:
* صفحه `return_url` شما `GET /sessions/{id}` را استعلام میکند، در حالی که مشتری همانجا نگاه میکند، پس
حالت رایج در یک ثانیه و مستقل از زمانبندی وبهوک حل میشود.
* یک بازبینی روزانه پرداختهای اخیر را میخواند و با سفارشهای باز مغایرتگیری میکند، که هر چیزی را هم که
وقتی سرور شما خواب بود پرداخت شده میگیرد.
**به وبهوک سریع پاسخ دهید و کار را بعد انجام دهید.** هندلری که پیش از پاسخ دادن کالا را ارسال میکند دیر
یا زود از مهلت ارسال عبور میکند، و تلاش مجدد، بررسی ایدمپوتنسی شما را تنها چیزی مییابد که میان یک سفارش
و دو سفارش ایستاده است.
**کد و شناسهها را ثبت کنید.** در هر خطا، کد ماشینی [قالب خطا](/errors)، شناسه جلسه و `reference_id` خودتان
را ثبت کنید. همین سهگانه برای پشتیبانی کوینلند کافی است تا رویداد دقیق را بدون رفتوبرگشت پیدا کند.
**روی سکوت هشدار بگذارید.** یک روز با صفر رویداد `payment.completed` در فروشگاهی که معمولاً پرداخت میگیرد،
نشانهای است که چیزی در سمت شما یا سمت ما شکسته است. هیچکس متوجه اندپوینت وبهوکی که بیصدا از کار افتاده
نمیشود، تا وقتی که حسابها کم بیایند.
## کاری که این ریل انجام نمیدهد [#کاری-که-این-ریل-انجام-نمیدهد]
ارزش دارد پیش از طراحی بر پایه آن بدانید:
* **بازگشت خودکار ندارد.** پرداخت تسویهشده نه خودبهخود برمیگردد و نه با فرایند اعتراض. برگرداندن پول یک
[بازپرداخت](/payouts) است که خودتان تصمیم به فرستادنش میگیرید، و کوینلند کارمزدی را که روی
پرداخت اصلی گرفته نگه میدارد.
* **دریافت جزئی و بلوکه کردن اعتبار ندارد.** پرداخت بهصورت کامل تسویه میشود یا انجام نمیشود.
* **تبدیل ارز ندارد.** شما در هر ارزی که میپذیرید قیمت میگذارید و مشتری دقیقاً همان را میپردازد.
همخوانی بین ارزها و ریسک بازار آن، مال شماست. [جلسههای پرداخت](/concepts/sessions) را ببینید.
* **پرداخت دورهای ندارد.** در این ریل اشتراک وجود ندارد. هر پرداخت تکرارشونده یک جلسه تازه است که مشتری
تأییدش میکند.
* **فقط مشتریان کوینلند میتوانند پرداخت کنند.** پرداختکننده به یک حساب کوینلند با موجودی نیاز دارد. این
ریلی برای رسیدن به مشتریان کوینلند است، نه یک درگاه کارت عمومی.
# کوینلند پی (/fa)
کوینلند پی به سایت شما امکان میدهد از هر کسی که در کوینلند موجودی دارد پرداخت بگیرد. شما روی سرور
خودتان یک **جلسه پرداخت** میسازید، مشتری را به ویجت میزبانیشده میفرستید، و مبلغ از موجودی کوینلند او
به حساب کسبوکار شما در کوینلند منتقل میشود.
در این مسیر هیچ بلاکچینی وجود ندارد. هر دو حساب در کوینلند هستند، پس پرداخت یک انتقال داخلی در دفتر
حساب است: در یک مرحله تسویه میشود، در همان درخواستی که مشتری تأیید میکند قطعی است، و در هیچ مبلغی
کارمزد شبکه ندارد. پرداخت ۴ تتری به همان اندازه بهصرفه است که پرداخت ۴٬۰۰۰ تتری.
## یکپارچهسازی چه شکلی است [#یکپارچهسازی-چه-شکلی-است]
**شما یک جلسه پرداخت میسازید** با `POST /api/pay/v1/sessions`، از سمت سرور و با کلید مخفی خودتان.
شناسه سفارش خودتان، یک عنوان، و قیمت را در هر ارزی که میپذیرید به آن میدهید.
**مشتری در ویجت میزبانیشده پرداخت میکند.** ویجت را با اسکریپت جایگذاری در یک پنجره بازشو باز میکنید
یا به `checkout_url` جلسه هدایتش میکنید. مشتری وارد کوینلند میشود، یکی از ارزهای شما را انتخاب میکند،
تأیید میکند و انتقال تسویه میشود.
**به شما اطلاع داده میشود و سفارش را تحویل میدهید.** کوینلند یک وبهوک امضاشده `payment.completed`
ارسال میکند؛ شما رکورد معتبر را با `GET /api/pay/v1/payments/{id}` میخوانید و کالا را آزاد میکنید.
برای دیدن نسخه آماده کپی و اجرای هر سه مرحله، از [شروع سریع](/quickstart) آغاز کنید.
## چرا این شکل طراحی شده است [#چرا-این-شکل-طراحی-شده-است]
**ویجت میزبانیشده است، و همین نکته اصلی است.** مشتری روی دامنه خودِ کوینلند احراز هویت میکند و تأیید
میکند، نه روی دامنه شما. هیچ رمز عبور، کد یکبارمصرف یا کد TOTP کوینلندی هرگز در صفحهای که شما سرو
میکنید تایپ نمیشود. یعنی نفوذ به بخش فرانتاند شما نمیتواند به نفوذ به حساب کوینلند مشتریانتان تبدیل
شود. سایت شما هیچوقت با یک اطلاعات محرمانه سر و کار ندارد و لازم نیست به آن اعتماد شود.
**شما در ارزها قیمت میگذارید و کوینلند هیچ تبدیلی انجام نمیدهد.** یک جلسه پرداخت فهرستی از گزینههای
`{currency, amount}` دارد و مشتری دقیقاً یکی را انتخاب میکند. اگر `10.5` تتر و `0.00012` بیتکوین
اعلام کنید، هر کدام را که انتخاب کند همان عدد جابهجا میشود. این ریل هیچ نرخ لحظهای اعلام نمیکند و
هیچ تبدیلی اعمال نمیکند، پس مبلغی که خواستهاید همان مبلغی است که میتوانید مغایرتگیری کنید.
**پرداختها قابل جعل نیستند.** هر پرداخت تکمیلشده یک [توکن رسید](/concepts/receipts) دارد که با کلید
مخفی وبهوک شما امضا شده است. مشتری بدون آن کلید نمیتواند چنین توکنی بسازد، پس رسید یک اثبات پرداخت
قابلحمل است که هر سیستمی در مجموعه شما میتواند آفلاین بررسی کند. با این حال، مبنای تحویل سفارش همان
وبهوک و API است، نه رسید.
## پیش از نوشتن هر کدی [#پیش-از-نوشتن-هر-کدی]
دو چیز باید از قبل وجود داشته باشد و هیچکدام از طریق API خودسرویس نیست:
1. **حساب شما به کسبوکار ارتقا داده میشود.** این کار را کارشناسان کوینلند انجام میدهند. با این ارتقا
حساب کاربری معمولی شما به یک کسبوکار تبدیل میشود و یک **کیفپول کسبوکار** مخصوص درآمدتان
میگیرد که از موجودی شخصیتان جدا نگه داشته میشود.
2. **یک کلید API دارید.** بعد از ارتقا، خودتان آن را در کنسول کسبوکار میسازید و فقط یک بار نمایش
داده میشود.
[شروع سریع](/quickstart) هر دو مرحله را قدمبهقدم توضیح میدهد.
## قدم بعدی [#قدم-بعدی]
| اگر میخواهید | بخوانید |
| ---------------------------------------------------- | ------------------------------------- |
| کوتاهترین مسیر تا یک پرداخت موفق | [شروع سریع](/quickstart) |
| ویجت را در صفحه پرداخت خودتان جای دهید | [ویجت](/widget) |
| چرخه حیات جلسه پرداخت و تلاش مجدد را بفهمید | [جلسههای پرداخت](/concepts/sessions) |
| کارمزد و شماره رسید را مغایرتگیری کنید | [پرداختها](/concepts/payments) |
| رسید را بدون تماس با ما اعتبارسنجی کنید | [رسیدها](/concepts/receipts) |
| به یک مشتری پول بفرستید یا پرداختی را بازپرداخت کنید | [پرداخت به مشتری](/payouts) |
| اعلانها را درست مدیریت کنید | [وبهوکها](/webhooks) |
| هر اندپوینت را فیلد به فیلد ببینید | [مرجع API](/reference) |
نسخههای ماشینخوان این سایت در [/llms.txt](/llms.txt) و [/llms-full.txt](/llms-full.txt) هستند و هر
صفحه یک نسخه Markdown روی مسیر خودش بههمراه `.md` دارد. سند OpenAPI در
[/openapi.json](/openapi.json) منتشر شده است.
# پرداخت به مشتری و بازپرداخت (/fa/payouts)
کوینلند پی پول را در دو جهت جابهجا میکند. گرفتن پرداخت یعنی یک [جلسه پرداخت](/concepts/sessions) که
مشتری تأییدش میکند. فرستادن پول یعنی **پرداخت به مشتری**: شما یک گیرنده و یک مبلغ تعیین میکنید و ارز از
کیفپول کسبوکار شما به موجودی کوینلند او میرود.
**بازپرداخت** همان عمل است که به پرداختی نشانه رفته که قبلاً گرفتهاید، و همان شیء را برمیگرداند. تفاوتش
این است که گیرنده و ارزِ بازپرداخت از رکورد همان پرداخت خوانده میشوند نه از شما، و اینکه بازپرداخت رایگان
است.
نه وضعیت «در انتظار»ی هست، نه مرحله تأییدی، و نه چیزی برای استعلام. تا وقتی پاسخ را بخوانید، کیفپول
شما بدهکار و گیرنده بستانکار شده است. وقفه زمانی روی این اندپوینت را مثل وقفه زمانی روی یک حواله بانکی
بگیرید: همان درخواست را با همان `reference_id` دوباره بفرستید و بگذارید ایدمپوتنسی به شما بگوید چه شده.
## کلید پرداخت به مشتری [#payout-key]
پرداخت به مشتری کلاس کلید مخصوص خودش را میخواهد. کلید پرداختگیریِ شما نمیتواند آن را صدا بزند، و کلید
پرداخت به مشتری نمیتواند جلسه بسازد.
```text
clpay_live_<64 hex> کلاس پرداختگیری -- جلسهها، پرداختها، رسیدها
clpay_payout_<64 hex> کلاس پرداخت به مشتری -- پرداخت به مشتری، بازپرداخت
```
در کنسول کسبوکار یکی بسازید و کلاس پرداخت به مشتری را انتخاب کنید. مثل کلید پرداختگیری، فقط یک بار نمایش
داده میشود و فقط هَش آن ذخیره میگردد، و از هر کلاس تا ۵ کلید فعال میتوانید داشته باشید.
کلید پرداخت به مشتری `GET /payments`، `GET /payments/{id}` و `GET /me` را هم میخواند، چون مغایرتگیری
میان آنچه فرستادهاید و آنچه گرفتهاید به هر دو طرف نیاز دارد. بیرون از این، روی سطح پرداختگیری کاری از آن
برنمیآید.
استفاده از کلاس اشتباه `PAY_WRONG_KEY_KIND` (۴۰۳) میگیرد، نه خطای احراز هویت — کلید معتبر است، فقط آن
یکی را میخواهید.
کلید پرداختگیری همان کلیدی است که سر از جاهای بیشتری درمیآورد: در سرویسی که جلسه میسازد، در محیط
آزمایشی، در خط استقرار. جدا کردن دو کلاس یعنی کلیدی که بیشتر کپی میشود نمیتواند از کیفپول شما پول
بیرون بفرستد، و باطل کردنش جلوی پرداختگیری شما را نمیگیرد.
## پیش از اولین پرداخت به مشتری [#arming]
پرداخت به مشتری خاموش است تا وقتی کوینلند آن را برای کسبوکار شما روشن کند، و روشن کردنش یعنی دو چیز، نه
یکی:
1. **پرداخت به مشتری برای حساب شما فعال شده باشد.**
2. **هر دو سقف دلاری شما تعیین شده باشد** — یک بیشینه برای هر پرداخت، و یک سقف برای ۲۴ ساعت گذشته.
سقفها اختیاری نیستند و حالت «بینهایت» وجود ندارد. حسابی که پرداخت به مشتریاش فعال شده اما سقفی برایش
تنظیم نشده، مسلح نیست و هر پرداختی را رد میکند: پولی که بیرون میرود هرگز بهخاطر جاافتادن یک تنظیم
بیحدومرز نمیشود.
تا وقتی همه اینها سر جایشان نباشند، هر نوشتنی `MERCHANT_PAYOUTS_DISABLED` (۴۰۳) میگیرد. همین یک کد،
کسبوکار معلق و ریلی را که کوینلند در کل پلتفرم متوقف کرده هر دو پوشش میدهد، چون راهحل هر دو یکی است:
با ما تماس بگیرید. این کدی نیست که با تلاش مجدد رد شود.
## پرداخت به مشتریای که به شما پرداخت کرده [#by-handle]
هر شیء پرداخت یک `payer_id` دارد: یک دستگیره مبهم برای مشتریای که پرداخت کرده، محدود به کسبوکار شما.
```json
{
"id": "b92e4d17-6c38-4a05-9f2b-1e7d3c8a5049",
"reference_id": "order-10492",
"currency": "usdt",
"amount": "24.90",
"payer_id": "7c1e5b90-3f42-4a86-9d05-2b8e4c1f6a37",
"paid_at": "2026-08-11T08:12:44.000Z"
}
```
این دستگیره پایدار است: همان مشتری اگر ماه بعد دوباره به شما پرداخت کند، همین مقدار را میآورد. شناسه
کاربری کوینلند نیست، برای هیچ کسبوکار دیگری معنایی ندارد، و چیزی درباره خودِ شخص فاش نمیکند. ضمناً تمام
آن چیزی است که برای تعیین گیرنده لازم دارید — کنار رکورد مشتریِ خودتان ذخیرهاش کنید و دیگر هیچوقت برای
پرداخت به او به نشانی ایمیل نیاز نخواهید داشت.
برای هر پرداختی که تا امروز روی این ریل گرفته شده دستگیره وجود دارد، حتی پرداختهایی که پیش از وجود این
قابلیت انجام شدهاند.
```bash
curl -X POST https://my.coinlandexchange.com/api/pay/v1/payouts \
-H "Authorization: Bearer $COINLAND_PAY_PAYOUT_KEY" \
-H "Content-Type: application/json" \
-d '{
"reference_id": "payout-2291",
"currency": "usdt",
"amount": "25.00",
"payer_id": "7c1e5b90-3f42-4a86-9d05-2b8e4c1f6a37",
"comment": "Cashback for order 10492"
}'
```
```json title="۲۰۱ ساخته شد"
{
"id": "9e3c7a41-0b52-4f18-8d6a-3c7e1f9b40d5",
"reference_id": "payout-2291",
"kind": "payout",
"status": "completed",
"currency": "usdt",
"amount": "25.00",
"debited_amount": "25.125",
"fee_amount": "0.125",
"fee_percent": "0.5",
"usd_value": "25.00",
"payer_id": "7c1e5b90-3f42-4a86-9d05-2b8e4c1f6a37",
"payment_id": null,
"comment": "Cashback for order 10492",
"created_at": "2026-08-11T09:31:04.000Z",
"settled_at": "2026-08-11T09:31:04.000Z"
}
```
لازم نیست ارز، یکی از ارزهایی باشد که در پرداختگیری میپذیرید. هر ارز فعالی که کیفپول کسبوکارتان دارد
قابل پرداخت است، و این وقتی اهمیت پیدا میکند که درآمدتان را به یک استیبلکوین تبدیل میکنید. تومان رد
میشود: این ریل در هر دو جهت فقط رمزارز است.
## پرداخت با نشانی ایمیل [#by-email]
برای گیرندهای که تا حالا به شما پرداخت نکرده دستگیرهای در کار نیست، پس مسیر دومی وجود دارد: نشانی را
جستوجو کنید، نامی را که برمیگردد به یک انسان نشان دهید، و توکن حاصل را خرج کنید.
این مسیر بهصورت پیشفرض خاموش است. کوینلند آن را برای هر کسبوکار جداگانه فعال میکند، و جایی که فعال
نباشد جستوجو `MERCHANT_PAYOUTS_DISABLED` میگیرد — همان پاسخی که یک نشانی غیرقابلپرداخت میگیرد، پس با
این اندپوینت حتی نمیشود فهمید قابلیت روشن است یا نه.
**نشانی را جستوجو کنید.**
```bash
curl -X POST https://my.coinlandexchange.com/api/pay/v1/payouts/recipients/lookup \
-H "Authorization: Bearer $COINLAND_PAY_PAYOUT_KEY" \
-H "Content-Type: application/json" \
-d '{"email": "customer@example.com"}'
```
```json title="۲۰۰ موفق"
{
"recipient_token": "v1.eyJtZXJjaGFudElkIjo0Miwi….9f3c1d60ab72",
"masked_name": "A**** B****",
"expires_at": "2026-08-11T09:41:22.000Z"
}
```
توکن ده دقیقه اعتبار دارد، فقط برای کسبوکاری که درخواستش کرده کار میکند، و گیرنده حلشده را داخل امضای
خودش حمل میکند. نشانی ایمیل هرگز روی درخواست پرداخت سفر نمیکند.
**`masked_name` را به یک انسان نشان دهید و تأیید بگیرید.**
ماسک آنقدر هست که کسی را که از قبل قصد پرداخت به او را داشتهاید بازبشناسید، و آنقدر نیست که یک غریبه
را شناسایی کند. کل نکتهاش همین است: نشانی اشتباه تایپشده را پیش از جابهجا شدن پول میگیرد، بیآنکه
فهرست مشتریان ما را به چیزی خواندنی تبدیل کند.
**پرداخت را بسازید و نام ماسکشده را عیناً برگردانید.**
```bash
curl -X POST https://my.coinlandexchange.com/api/pay/v1/payouts \
-H "Authorization: Bearer $COINLAND_PAY_PAYOUT_KEY" \
-H "Content-Type: application/json" \
-d '{
"reference_id": "payout-2292",
"currency": "usdt",
"amount": "40.50",
"recipient_token": "v1.eyJtZXJjaGFudElkIjo0Miwi….9f3c1d60ab72",
"recipient_confirm": "A**** B****"
}'
```
`recipient_confirm` باید کاراکتر به کاراکتر با `masked_name` یکی باشد. ناهمخوانی رد میشود، و همین است
که مرحله دوم را به یک بررسی واقعی تبدیل میکند، نه صفحهای که کسی از رویش رد شود.
پاسخ برای گیرنده یک `payer_id` میآورد، پس پرداخت بعدی به همان شخص میتواند همه این مراحل را رد کند و از
دستگیره استفاده کند.
نشانیای که حساب کوینلند ندارد، حساب غیرفعال، حسابی که احراز هویتش را تمام نکرده، توکن منقضی، توکنی که
برای کسبوکار دیگری ساخته شده، تأییدی که نمیخوانَد — همه `PAY_RECIPIENT_INVALID` (۴۲۲) میگیرند.
اندپوینت عامدانه به شما نمیگوید کدامیک، چون جستوجویی که اینها را از هم جدا کند راهی میشود برای
فهمیدن اینکه چه کسی نزد ما حساب دارد.
جستوجو برای هر کسبوکار سهمیه هم دارد. عبور از سهمیه `PAY_LOOKUP_THROTTLED` (۴۲۹) است و فقط جستوجو را
متوقف میکند — پرداخت با دستگیره سر جایش کار میکند.
## بازپرداخت [#refunds]
بازپرداخت، ارز را به مشتریای که به شما پرداخت کرده برمیگرداند. آن را با شناسه پرداخت یا شماره رسید
خطاب کنید و فقط یک مبلغ بفرستید:
```bash
curl -X POST https://my.coinlandexchange.com/api/pay/v1/payments/CLP-10492-8F3A/refund \
-H "Authorization: Bearer $COINLAND_PAY_PAYOUT_KEY" \
-H "Content-Type: application/json" \
-d '{
"reference_id": "refund-10492-1",
"amount": "10.00",
"comment": "One item returned"
}'
```
فیلد گیرنده و فیلد ارز وجود ندارد، و این عمدی است: هر دو از خودِ پرداخت میآیند، پس بازپرداخت فقط
میتواند از همان راهی برگردد که پول آمده بود.
* **بازپرداخت جزئی مجاز است** و میتوانید چند بار روی یک پرداخت انجامش دهید.
* **سقف، تجمعی است** و برابر `charged_amount` همان پرداخت. بازپرداختی که جمع را از آن عبور دهد
`PAY_REFUND_EXCEEDS_PAYMENT` (۴۲۲) میگیرد، و خطا میگوید تا حالا چقدر بازپرداخت شده است.
* **بازپرداخت رایگان است.** `fee_amount` برابر `"0"` و `debited_amount` برابر `amount` است، پس
بازپرداخت فقط خودِ ارز را برای شما هزینه دارد.
کوینلند کارمزدی را که روی پرداخت اصلی گرفته نگه میدارد و در مسیر برگشت کارمزد تازهای نمیگیرد. یک
جریان یک بار کارمزد دارد: نه برای فروشی که برگشته دو بار از شما گرفته میشود، و نه کارمزد اصلی برگردانده
میشود.
نتیجه بهشکل یک پرداخت با `kind` برابر `refund` خوانده میشود و شناسه پرداختِ بازپرداختشده در
`payment_id` میآید.
## کارمزد [#fees]
پرداخت به مشتری با همان نرخ پلکانی پرداختهایتان حساب میشود و **همیشه شما آن را میپردازید**. گیرنده
دقیقاً همان `amount` را که تعیین کردهاید دریافت میکند — هیچ حالتی وجود ندارد که به او یک عدد نشان داده
شود و عدد دیگری به حسابش بنشیند.
```text
گیرنده دریافت میکند amount
کیفپول شما میپردازد debited_amount == amount + fee_amount
```
رابطه `debited_amount - amount == fee_amount` روی هر پرداختی دقیقاً برقرار است. کیفپولتان را با
`debited_amount` و آنچه به گیرنده قول دادهاید را با `amount` مغایرتگیری کنید؛ استفاده از یک عدد برای هر
دو همان چیزی است که دفتر حساب را دچار انحراف میکند.
حجم پرداخت به مشتری در حجم دلاری ۳۰ روزهای که پله کارمزد شما را تعیین میکند به حساب میآید، پس پولی که
میفرستید به رسیدن شما به نرخ بهتر کمک میکند. آستانههای **تعداد** همچنان فقط پرداختها را میشمارند.
## سقفها [#limits]
دو سقف اعمال میشود، هر دو دلاری، و هر دو را کوینلند تعیین میکند نه شما.
| سقف | روی چه چیزی | عبور از آن |
| ----------------- | ---------------- | ------------------------------------------------------------------ |
| بیشینه هر پرداخت | یک پرداخت | `PAY_PAYOUT_LIMIT` (۴۲۲)، مقدار `details.limit` برابر `per-payout` |
| سقف ۲۴ ساعت گذشته | جمع ۲۴ ساعت اخیر | `PAY_PAYOUT_LIMIT` (۴۲۲)، مقدار `details.limit` برابر `daily` |
بازپرداخت از بیشینه هر پرداخت معاف است — سقفی کمتر از یک پرداخت نباید آن پرداخت را غیرقابلبازگشت کند —
اما همچنان در سقف روزانه به حساب میآید.
هر دو با دلار سنجیده میشوند، یعنی پرداخت فقط وقتی میرود که همین حالا بشود ارز را ارزشگذاری کرد. اگر نرخ
زندهای در دسترس نباشد پرداخت با `PAY_RATE_UNAVAILABLE` (۵۰۳) رد میشود، بهجای آنکه بدون سنجش برود.
درخواست مشکلی ندارد؛ دوباره تلاش کنید.
اگر کیفپول کسبوکارتان `amount + fee` را پوشش ندهد، پاسخ `INSUFFICIENT_BALANCE` (۴۲۲) است. در کنسول از
موجودی اسپات به کیفپول کسبوکار منتقل کنید و دوباره تلاش کنید.
## ایدمپوتنسی [#idempotency]
`reference_id` کلید ایدمپوتنسی شماست و دقیقاً مثل جلسههای پرداخت کار میکند:
* همان `reference_id` با محتوای **یکسان**، پرداخت اصلی را با کد `۲۰۱` برمیگرداند. انتقال دومی رخ
نمیدهد.
* همان `reference_id` با محتوای **متفاوت**، `PAY_DUPLICATE_REFERENCE` (۴۰۹) میگیرد.
```js
// A timeout tells you nothing about whether the payout settled. Resend the
// identical request -- never a fresh reference_id, which is how one payout
// becomes two.
async function payOut(body) {
for (let attempt = 0; attempt < 3; attempt++) {
try {
return await post("/api/pay/v1/payouts", body);
} catch (err) {
if (!isTimeout(err)) throw err;
await sleep(2 ** attempt * 1000);
}
}
// Still unsure? Read it back by your own id.
return get(`/api/pay/v1/payouts/${body.reference_id}`);
}
```
`GET /payouts/{id}` هم شناسه پرداخت را میپذیرد و هم `reference_id` خودتان را، و همین خط آخر را به راهی
مطمئن برای بستن پرونده پس از یک خطای شبکه تبدیل میکند.
## چطور بفهمید انجام شده [#webhook]
چون پرداخت داخل همان درخواست تسویه میشود، پاسخ خودش معتبر است و معمولاً به چیز دیگری نیاز ندارید. یک
وبهوک `payout.completed` هم ارسال میشود، برای حالتی که پرداخت جایی بیرون از کد خودتان ساخته شده باشد —
مثلاً از کنسول کسبوکار:
```json title="payout.completed"
{
"event_id": "a17b3e50-9d24-4c81-b6f3-5e0a2c7d1948",
"type": "payout.completed",
"payout_id": "9e3c7a41-0b52-4f18-8d6a-3c7e1f9b40d5",
"reference_id": "payout-2291",
"kind": "payout",
"status": "completed"
}
```
همان طرح امضا، همان پنجره پنجدقیقهای، همان قاعده حذف تکراریها، و همان هشدار: محتوا یک سرنخ است. هیچ
مبلغی حمل نمیکند و هر چیزی که بر پایهاش عمل میکنید باید از `GET /payouts/{id}` بیاید. کد اعتبارسنجی در
[وبهوکها](/webhooks) است.
توجه کنید که `kind` روی همین رویداد پرداخت به مشتری را از بازپرداخت جدا میکند، پس هندلری که مثلاً موجودی
وفاداری مشتری را روی پرداختها بهروز میکند باید بر پایه آن شاخه بگذارد، نه اینکه فرض کند.
## خطاها [#errors]
| کد | HTTP | معنا | چه کاری کنید |
| ---------------------------- | ---- | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `MERCHANT_PAYOUTS_DISABLED` | ۴۰۳ | پرداخت به مشتری برای کسبوکار شما مسلح نیست، یا مسیر ایمیل برایتان فعال نشده | با کوینلند تماس بگیرید. با تلاش مجدد رد نمیشود |
| `PAY_WRONG_KEY_KIND` | ۴۰۳ | کلید پرداختگیری روی مسیر پرداخت به مشتری، یا برعکس | از کلید کلاس دیگر استفاده کنید |
| `PAY_RECIPIENT_INVALID` | ۴۲۲ | گیرنده قابل پرداخت نیست، یا هر دو مسیر گیرنده را فرستادهاید، یا هیچکدام | دستگیره را بررسی یا جستوجو را تکرار کنید. یک کد عامدانه همه دلیلها را پوشش میدهد |
| `PAY_PAYOUT_LIMIT` | ۴۲۲ | عبور از بیشینه هر پرداخت یا سقف ۲۴ ساعته | `details.limit` را بخوانید. پرداخت را بشکنید، منتظر پایان بازه بمانید، یا از کوینلند افزایش سقف بخواهید |
| `PAY_REFUND_EXCEEDS_PAYMENT` | ۴۲۲ | جمع بازپرداختها از `charged_amount` آن پرداخت عبور میکند | باقیمانده را بازپرداخت کنید؛ `details.already_refunded` میگوید چقدر رفته است |
| `PAY_LOOKUP_THROTTLED` | ۴۲۹ | سهمیه جستوجوی دقیقه یا روز تمام شده | عقب بکشید. پرداخت با دستگیره تأثیری نمیگیرد |
| `INSUFFICIENT_BALANCE` | ۴۲۲ | کیفپول کسبوکار `amount + fee` را پوشش نمیدهد | کیفپول را از موجودی اسپات شارژ و دوباره تلاش کنید |
| `PAY_RATE_UNAVAILABLE` | ۵۰۳ | ارز نرخ زنده دلاری ندارد، پس سقفها قابل اعمال نیستند | دوباره تلاش کنید؛ خودِ درخواست مشکلی ندارد |
| `PAY_DUPLICATE_REFERENCE` | ۴۰۹ | همان `reference_id` با محتوای متفاوت | محتوای اصلی را دوباره بفرستید، یا برای پرداختی که واقعاً تازه است شناسه تازه بگذارید |
فهرست کامل، شامل کدهای مشترک با پرداختگیری، در صفحه [خطاها](/errors) است.
## فهرست بررسی [#checklist]
* کلید پرداخت به مشتری جداگانه ساخته و جدا از کلید پرداختگیری ذخیره شده باشد.
* `payer_id` در لحظه پرداخت کنار رکورد مشتری خودتان ذخیره شود.
* `reference_id` از چیزی پایدار در سیستم خودتان بیاید، نه یک مقدار تصادفی برای هر تلاش.
* وقفه زمانی با همان درخواست تکرار شود، هرگز با شناسه تازه.
* هر دو مقدار `amount` و `debited_amount` بهصورت رشته اعشاری ذخیره شوند.
* پیش از هر پرداخت از مسیر ایمیل، یک انسان `masked_name` را تأیید کند.
* `PAY_PAYOUT_LIMIT` یک وضعیت کسبوکاری تلقی شود نه یک باگ — کسی باید از آن باخبر شود.
# شروع سریع (/fa/quickstart)
این کل یکپارچهسازی از ابتدا تا انتها است. پنج مرحله، بدون نیاز به هیچ SDK.
## ۱. حسابتان را به کسبوکار ارتقا دهید [#۱-حسابتان-را-به-کسبوکار-ارتقا-دهید]
**نه ثبتنام جداگانهای برای کسبوکار وجود دارد و نه مسیر ثبتنام خاصی.** کوینلند پی روی یک حساب
کاربری معمولی کوینلند کار میکند و دسترسی در سه مرحله به دست میآید:
**ابتدا مانند یک کاربر عادی ثبتنام کنید** در
[my.coinlandexchange.com/register](https://my.coinlandexchange.com/register)، از همان مسیر ثبتنام
معمولی. هیچ چیز این مرحله مخصوص کسبوکار نیست.
**سپس از بخش پشتیبانی داشبورد یک تیکت ثبت کنید** و فعالسازی حساب کسبوکار و دسترسی به کوینلند پی را
درخواست کنید. اطلاعات کسبوکارتان را در همان تیکت بنویسید.
**تیم کوینلند بررسی میکند و در همان تیکت پاسخ میدهد.** پس از تأیید، حساب شما ارتقا مییابد و بخش
«مدیریت کسبوکار» در داشبوردتان ظاهر میشود — همان جایی که کلید API میسازید و ویجت را تنظیم میکنید.
نه فرم درخواستی وجود دارد و نه فعالسازی خودکار: یک نفر تیکت شما را میخواند.
بعد از ارتقا، بخش **مدیریت کسبوکار** (کنسول کسبوکار) در داشبورد شما و در نشانی
`https://my.coinlandexchange.com/business` ظاهر میشود. آنجا اینها را تعیین میکنید:
* **ارزهای پذیرفتهشده.** فقط اینها میتوانند در `amounts` یک جلسه پرداخت بیایند. هر چیز دیگری با
`PAY_CURRENCY_NOT_ACCEPTED` رد میشود. کوینلند پی فقط از ارزهای دیجیتال پشتیبانی میکند؛ پرداخت
تومانی در این سرویس ارائه نمیشود.
* **نام نمایشی و لوگو.** آنچه مشتری در ویجت، بالای عنوان سفارش، میبیند.
* **نشانی وبهوک** (فقط https) و **کلید مخفی وبهوک** شما، که توکنهای رسید را هم امضا میکند.
* **مدت اعتبار جلسه**، یعنی TTL پیشفرض یک جلسه پرداخت.
* **تبدیل خودکار**، که پیشفرض خاموش است: هر ارزی را که دریافت میکنید به تتر (USDT) بفروشید تا
فقط یک دارایی نگه دارید. این یک معامله واقعی در بازار است، نه یک تبدیل رایگان —
[پرداختها](/concepts/payments) را ببینید.
پرداختهای تکمیلشده در یک **کیفپول کسبوکارِ اختصاصی** مینشینند که از موجودی اسپات و معاملاتی شخصی
شما جداست. برای خرج کردن یا برداشت درآمدتان، آن را از کیفپول کسبوکار به کیفپول اصلی منتقل میکنید —
فوری و بدون کارمزد — و بعد مثل همیشه معامله یا برداشت میکنید. [پرداختها](/concepts/payments) را
ببینید.
## ۲. یک کلید API بسازید [#۲-یک-کلید-api-بسازید]
در کنسول کسبوکار یک کلید بسازید. شکلش این است:
```text
clpay_live_4f9d2c8a1b7e6f3d0a5c9b2e8f1a6d4c7b0e3f9a2c5d8b1e4f7a0c3d6b9e2f5a
```
کلید کامل تنها در لحظه ساخت نشان داده میشود. کوینلند فقط هش آن را ذخیره میکند، نه مقدارش، پس کلید
گمشده قابل بازیابی نیست — آن را باطل کنید و کلید تازه بسازید. فقط پیشوند کلید نگه داشته میشود تا در
کنسول بتوانید کلیدهایتان را از هم تشخیص دهید.
آن را همانطور نگه دارید که رمز عبور پایگاه داده را نگه میدارید: در سیستم مدیریت اسرار سرورتان، هرگز در
یک مخزن کد، باندل مرورگر، یا فایل اجرایی اپلیکیشن موبایل. هر درخواست کلید را بهصورت توکن bearer حمل
میکند:
```text
Authorization: Bearer clpay_live_<64 hex>
```
با یک فراخوانی هم درست کار کردن کلید را تأیید کنید و هم ببینید مجاز به قیمتگذاری در چه ارزهایی هستید:
```bash title="بررسی کلید"
curl https://my.coinlandexchange.com/api/pay/v1/me \
-H "Authorization: Bearer $COINLAND_PAY_KEY"
```
```json title="پاسخ"
{
"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"
}
```
`accepted_currencies` را در زمان راهاندازی برنامه بخوانید و فهرست ارزها را در کد ثابت نکنید: این فهرست
در کنسول و بدون نیاز به انتشار نسخه جدید از سمت شما تغییر میکند.
## ۳. یک جلسه پرداخت بسازید [#۳-یک-جلسه-پرداخت-بسازید]
یک فراخوانی برای هر سفارش، از سرور خودتان، در همان لحظهای که مشتری پرداخت را انتخاب میکند.
```ts
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
}
export async function createSession(order: {
id: string;
number: string;
amounts: Amount[];
}) {
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,
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();
}
```
```js
const API = "https://my.coinlandexchange.com";
const apiKey = process.env.COINLAND_PAY_KEY; // clpay_live_...
export async function createSession(order) {
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}`,
// decimal STRINGS, never floats
amounts: order.amounts,
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();
}
```
```python
import os
import requests
API = "https://my.coinlandexchange.com"
API_KEY = os.environ["COINLAND_PAY_KEY"] # clpay_live_...
def create_session(order):
res = requests.post(
f"{API}/api/pay/v1/sessions",
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
},
json={
"reference_id": order["id"], # your order id, and your idempotency key
"title": f"Order {order['number']}",
# Decimal STRINGS. Never float() an amount, and never let a JSON
# encoder turn a Decimal into one.
"amounts": order["amounts"],
"return_url": "https://example.com/checkout/done",
"cancel_url": "https://example.com/cart",
},
timeout=15,
)
if not res.ok:
code = "UNKNOWN"
try:
code = res.json()["errors"]["error"][0]
except (ValueError, KeyError, IndexError):
pass
raise RuntimeError(f"coinland pay {res.status_code}: {code}")
return res.json()
```
```rust
// Cargo.toml: reqwest = { version = "0.12", features = ["json"] }
// serde = { version = "1", features = ["derive"] }
// serde_json = "1", tokio = { version = "1", features = ["full"] }
use serde::{Deserialize, Serialize};
const API: &str = "https://my.coinlandexchange.com";
#[derive(Serialize)]
pub struct Amount<'a> {
pub currency: &'a str,
/// Decimal STRING, never an f64.
pub amount: &'a str,
}
#[derive(Serialize)]
struct CreateSession<'a> {
reference_id: &'a str,
title: &'a str,
amounts: &'a [Amount<'a>],
return_url: &'a str,
cancel_url: &'a str,
}
#[derive(Deserialize, Debug)]
pub struct Session {
pub id: String,
pub status: String,
pub checkout_url: String,
}
pub async fn create_session(
http: &reqwest::Client,
api_key: &str, // clpay_live_...
order_id: &str,
title: &str,
amounts: &[Amount<'_>],
) -> Result> {
let res = http
.post(format!("{API}/api/pay/v1/sessions"))
.bearer_auth(api_key)
.json(&CreateSession {
reference_id: order_id, // your order id, and your idempotency key
title,
amounts,
return_url: "https://example.com/checkout/done",
cancel_url: "https://example.com/cart",
})
.send()
.await?;
if !res.status().is_success() {
let status = res.status();
let body = res.text().await.unwrap_or_default();
return Err(format!("coinland pay {status}: {body}").into());
}
Ok(res.json().await?)
}
```
```php
true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('COINLAND_PAY_KEY'), // clpay_live_...
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'reference_id' => $order->id, // your order id, and your idempotency key
'title' => 'Order ' . $order->number,
// Decimal STRINGS, never floats.
'amounts' => [['currency' => 'usdt', 'amount' => $order->total_usdt]],
'return_url' => 'https://example.com/checkout/done',
'cancel_url' => 'https://example.com/cart',
]),
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status < 200 || $status >= 300) {
$code = json_decode($raw, true)['errors']['error'][0] ?? 'UNKNOWN';
throw new RuntimeException("coinland pay {$status}: {$code}");
}
$session = json_decode($raw, true);
```
```bash
curl -X POST https://my.coinlandexchange.com/api/pay/v1/sessions \
-H "Authorization: Bearer $COINLAND_PAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"reference_id": "order-10492",
"title": "Order 10492",
"description": "2 items",
"amounts": [
{ "currency": "usdt", "amount": "24.90" },
{ "currency": "btc", "amount": "0.00027" }
],
"return_url": "https://example.com/checkout/done",
"cancel_url": "https://example.com/cart",
"metadata": { "cart_id": "c_88213" }
}'
```
```json title="۲۰۱ ساخته شد"
{
"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"
}
```
سه نکته که ارزش دارد بار اول درست انجام شوند:
* **مبالغ رشته اعشاری هستند.** `"24.90"`، نه `24.9`. یک عدد اعشاری شناور نمیتواند هر مبلغ دهدهی را
دقیق نگه دارد، و خطای گردکردن در قیمت یعنی خطای گردکردن در آنچه دریافت میکنید.
* **`reference_id` کلید ایدمپوتنسی شماست.** ارسال مجدد با همان شناسه و همان محتوا، جلسه اصلی را برمیگرداند
و جلسه دومی نمیسازد. [جلسههای پرداخت](/concepts/sessions) را ببینید.
* **`session.id` را روی سفارشتان ذخیره کنید**، پیش از آنکه مشتری را جایی بفرستید. این همان چیزی است که
بعداً با آن وبهوک را به سفارش وصل میکنید.
## ۴. مشتری را به ویجت بفرستید [#۴-مشتری-را-به-ویجت-بفرستید]
یا ویجت را در یک پنجره بازشو روی صفحه خودتان باز کنید، که تجربه بهتری است:
```html title="پنجره بازشو"
```
یا بدون هیچ جاوااسکریپتی هدایت کنید:
```text title="هدایت"
302 Location: https://my.coinlandexchange.com/pay/3a7f21e8-9c04-4d6b-8e15-7b2a9f3c1d60
```
بعد از پرداخت، مشتری روی `return_url` شما فرود میآید و `?receipt=&payment_id=` به آن اضافه
شده است. جزئیات کامل، از جمله حالت بستهشدن پنجره بازشو، در [ویجت](/widget) است.
هدایت کاری است که مرورگر انجام میدهد. میتواند با بستن تب، شبکه ناپایدار یا خاموش شدن گوشی از دست
برود، و میتواند دستی توسط کسی تایپ شود که هرگز پرداختی نکرده است. صفحه فرود را نشانهای برای نمایش
حالت انتظار و استعلام بگیرید، نه اثبات. تحویل در مرحله ۵ انجام میشود.
## ۵. وبهوک را دریافت کنید و سفارش را تحویل دهید [#۵-وبهوک-را-دریافت-کنید-و-سفارش-را-تحویل-دهید]
کوینلند به نشانی ثبتشده شما درخواست POST میفرستد که با کلید مخفی وبهوک شما امضا شده است:
```json title="payment.completed"
{
"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"
}
```
امضا را بررسی کنید، بر اساس `event_id` تکراریها را حذف کنید، سپس رکورد معتبر را بخوانید و تحویل دهید:
```js title="Node (Express)"
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.COINLAND_PAY_WEBHOOK_SECRET;
// بدنه خام همان چیزی است که امضا شده. هر پارسر JSON که آن را دوباره سریالایز
// کند امضا را خراب میکند، پس پارس کردن بعد از بررسی انجام میشود، نه قبل.
app.post(
"/webhooks/coinland",
express.raw({ type: "application/json" }),
async (req, res) => {
const header = req.get("x-pay-signature") ?? "";
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=").map((s) => s.trim())),
);
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!Number.isFinite(age) || age > 300) return res.sendStatus(400);
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${parts.t}.${req.body.toString("utf8")}`)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(parts.v1 ?? "", "utf8");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(400);
}
// امضا درست است. سریع پاسخ بدهید و بعد کار را انجام دهید: یک هندلر کند
// هندلری است که دوباره فراخوانی میشود.
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;
// محتوای وبهوک یک سرنخ است. این مرجع است.
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, // آنچه مشتری پرداخت کرد
net: payment.net_amount, // آنچه به حساب شما نشست
receiptNo: payment.receipt_no,
});
await markHandled(event.event_id);
} catch (err) {
// شما همین حالا ۲۰۰ پاسخ دادهاید، پس کوینلند دوباره تلاش نمیکند.
// خطا را لاگ کنید و بگذارید چرخه مغایرتگیری روزانهتان سفارش را پیدا کند.
console.error("coinland pay fulfilment failed", err);
}
},
);
```
این یک یکپارچهسازی کامل است. [وبهوکها](/webhooks) تلاشهای مجدد، رویداد `session.expired` و پیامد
پاسخ غیر ۲xx از سمت شما را پوشش میدهد.
## پیش از انتشار چه چیزی را بررسی کنید [#پیش-از-انتشار-چه-چیزی-را-بررسی-کنید]
* نشانی وبهوک شما https است و از اینترنت عمومی قابل دسترسی است.
* هندلر شما در چند ثانیه پاسخ ۲xx میدهد و کارش را بعد از آن انجام میدهد.
* بر اساس `event_id` تکراریها را حذف میکنید، چون ارسال مجدد یک رفتار عادی است نه خطا.
* تحویل را بر پایه وبهوک یا API انجام میدهید، هرگز فقط بر پایه `return_url`.
* کلید شما فقط سمت سرور است.
فهرست کامل در [آماده انتشار](/go-live) است.
# وبهوکها (/fa/webhooks)
وبهوک راهی است که کوینلند رخ دادن چیزی را به شما اطلاع میدهد، بدون آنکه شما مجبور به استعلام دورهای
باشید. وبهوکها درخواستهای POST امضاشده به نشانی httpsای هستند که در کنسول کسبوکار ثبت میکنید.
سه رویداد وجود دارد، و یک قاعده که از همه آنها مهمتر است: **محتوای وبهوک یک سرنخ است، نه یک واقعیت.**
پیش از عمل کردن بر پایه آن، رکورد معتبر را بخوانید.
## رویدادها [#رویدادها]
| نوع | چه زمانی | چه کاری کنید |
| ------------------- | ---------------------------------------------------- | ------------------------------------------------ |
| `payment.completed` | یک جلسه پرداخت شد و انتقال تسویه شد | پرداخت را بخوانید، سفارش را تحویل دهید |
| `session.expired` | یک جلسه پرداختنشده به `expires_at` رسید | سبد یا رزرو را آزاد کنید |
| `payout.completed` | یک [پرداخت به مشتری یا بازپرداخت](/payouts) تسویه شد | پرداخت را بخوانید و پرونده مربوط به آن را ببندید |
```json title="payment.completed"
{
"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"
}
```
```json title="session.expired"
{
"event_id": "5c8b1f47-2a93-4d06-b1e8-7f0c3d9a5b62",
"type": "session.expired",
"session_id": "3a7f21e8-9c04-4d6b-8e15-7b2a9f3c1d60",
"reference_id": "order-10492",
"status": "expired"
}
```
```json title="payout.completed"
{
"event_id": "a17b3e50-9d24-4c81-b6f3-5e0a2c7d1948",
"type": "payout.completed",
"payout_id": "9e3c7a41-0b52-4f18-8d6a-3c7e1f9b40d5",
"reference_id": "payout-2291",
"kind": "payout",
"status": "completed"
}
```
رویداد `payout.completed` برای بازپرداخت هم فرستاده میشود — مقدار `kind` یا `payout` است یا `refund`،
پس بر پایه آن شاخه بگذارید و فرض نکنید. توجه کنید پرداختی که خودتان ساختهاید تا وقتی فراخوانی API شما
برگردد تسویه شده است، پس این رویداد بیشتر برای پرداختهایی اهمیت دارد که از کنسول کسبوکار انجام میشوند.
انواع رویداد جدید افزودنی هستند و میتوانند بدون تغییر نسخه ظاهر شوند، پس **همیشه یک شاخه پیشفرض داشته
باشید** که آنچه را نمیشناسد نادیده بگیرد. هندلری که روی `type` ناشناس خطا میدهد یک افزوده معمول و
سازگار با نسخه قبل را به یک قطعی در سمت شما تبدیل میکند.
## بررسی امضا [#بررسی-امضا]
هر درخواست این هدر را دارد:
```text
x-pay-signature: t=1754999071,v1=3b8a5f9c2d1e...
```
* **`t`** مهر زمانی یونیکس بر حسب ثانیه در لحظه امضا است.
* **`v1`** حاصل `HMAC-SHA256(webhook_secret, "{t}.{rawBody}")` بهصورت hex با حروف کوچک است.
به رشتهای که امضا میشود دقت کنید: مهر زمانی، یک نقطه، و سپس **بدنه خام درخواست دقیقاً همانطور که ارسال
شده**. قرار دادن `t` در آنچه امضا میشود همان چیزی است که جلوی بازپخش یک درخواست قدیمی و واقعی با هدری
تازه را میگیرد.
```ts
import crypto from "node:crypto";
import express from "express";
const app = express();
const webhookSecret = process.env.COINLAND_PAY_WEBHOOK_SECRET!;
// The RAW body is what was signed. A JSON parser that re-serialises the request
// produces different bytes — different key order, different whitespace — and
// every signature fails for reasons that look like a bug in ours. Take the raw
// buffer, verify, then parse.
app.post(
"/webhooks/coinland",
express.raw({ type: "application/json" }),
(req, res) => {
const header = req.get("x-pay-signature") ?? "";
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=").map((s) => s.trim())),
);
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp)) return res.sendStatus(400);
// Five-minute window, checked in BOTH directions so a clock ahead of ours
// is rejected too.
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return res.sendStatus(400);
const expected = crypto
.createHmac("sha256", webhookSecret)
.update(`${timestamp}.${req.body.toString("utf8")}`)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(parts.v1 ?? "", "utf8");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(400);
}
// Acknowledge FIRST, work after: a slow handler is a retried handler.
res.sendStatus(200);
void handleEvent(JSON.parse(req.body.toString("utf8")));
},
);
```
```js
const crypto = require("node:crypto");
const express = require("express");
const app = express();
const webhookSecret = process.env.COINLAND_PAY_WEBHOOK_SECRET;
// The RAW body is what was signed — verify before parsing, never after.
app.post(
"/webhooks/coinland",
express.raw({ type: "application/json" }),
(req, res) => {
const header = req.get("x-pay-signature") || "";
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=").map((s) => s.trim())),
);
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp)) return res.sendStatus(400);
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return res.sendStatus(400);
const expected = crypto
.createHmac("sha256", webhookSecret)
.update(`${timestamp}.${req.body.toString("utf8")}`)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(parts.v1 || "", "utf8");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(400);
}
res.sendStatus(200); // acknowledge first, work after
handleEvent(JSON.parse(req.body.toString("utf8")));
},
);
```
```python
import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
WEBHOOK_SECRET = os.environ["COINLAND_PAY_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/coinland")
def coinland_webhook():
# get_data() is the RAW body. Anything that re-serialises the JSON changes
# the bytes and every signature will fail.
raw = request.get_data()
header = request.headers.get("x-pay-signature", "")
parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
try:
timestamp = int(parts["t"])
except (KeyError, ValueError):
abort(400)
# Five-minute window, both directions.
if abs(time.time() - timestamp) > 300:
abort(400)
expected = hmac.new(
WEBHOOK_SECRET, f"{timestamp}.".encode() + raw, hashlib.sha256
).hexdigest()
# compare_digest is the constant-time comparison. Never use ==.
if not hmac.compare_digest(expected, parts.get("v1", "")):
abort(400)
# Acknowledge fast, work after — hand the event to a queue or a thread.
enqueue_event(request.get_json(force=True))
return "", 200
```
```rust
// Cargo.toml: axum = "0.8", hmac = "0.12", sha2 = "0.10", hex = "0.4"
use axum::{
body::Bytes,
http::{HeaderMap, StatusCode},
};
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac;
pub async fn coinland_webhook(headers: HeaderMap, body: Bytes) -> StatusCode {
let secret = std::env::var("COINLAND_PAY_WEBHOOK_SECRET").unwrap_or_default();
let header = headers
.get("x-pay-signature")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let (mut timestamp, mut signature) = (None, None);
for part in header.split(',') {
match part.trim().split_once('=') {
Some(("t", v)) => timestamp = v.parse::().ok(),
Some(("v1", v)) => signature = Some(v),
_ => {}
}
}
let (Some(timestamp), Some(signature)) = (timestamp, signature) else {
return StatusCode::BAD_REQUEST;
};
// Five-minute window, both directions.
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
if (now - timestamp).abs() > 300 {
return StatusCode::BAD_REQUEST;
}
// `body` is the raw bytes as transmitted — exactly what was signed.
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
mac.update(format!("{timestamp}.").as_bytes());
mac.update(&body);
let Ok(provided) = hex::decode(signature) else {
return StatusCode::BAD_REQUEST;
};
// verify_slice IS the constant-time compare; never compare hex strings.
if mac.verify_slice(&provided).is_err() {
return StatusCode::BAD_REQUEST;
}
// Acknowledge fast, work after.
tokio::spawn(handle_event(body));
StatusCode::OK
}
```
```php
300) {
http_response_code(400);
exit;
}
$expected = hash_hmac(
'sha256',
$timestamp . '.' . $raw,
getenv('COINLAND_PAY_WEBHOOK_SECRET')
);
// hash_equals is the constant-time compare. A === on a signature is a real
// weakness, not a theoretical one.
if (!hash_equals($expected, $parts['v1'] ?? '')) {
http_response_code(400);
exit;
}
http_response_code(200); // acknowledge first
$event = json_decode($raw, true);
enqueue_event($event); // work after
```
سه الزام، که هیچکدام قابل چشمپوشی نیست:
1. **بایتهای خام دقیق را هش کنید.** سریالایز دوباره JSON امضا را خراب میکند. بیشتر فریمورکها را باید
وادار کنید بدنه خام را به شما بدهند؛ این را پیش از هر کار دیگری انجام دهید.
2. **پنجره پنجدقیقهای را اعمال کنید.** بدون آن، امضایی که یک بار ضبط شود برای همیشه معتبر است.
3. **در زمان ثابت مقایسه کنید.** `crypto.timingSafeEqual`، `hash_equals`، `hmac.compare_digest`.
همان کلید مخفی، [توکنهای رسید](/concepts/receipts) را هم امضا میکند، پس یک مقدار برای محافظت و یک تابع
رمزنگاری برای درست پیاده کردن وجود دارد.
## بر پایه event\_id تکراریها را حذف کنید [#بر-پایه-event_id-تکراریها-را-حذف-کنید]
**فرض کنید هر رویداد بیش از یک بار میرسد.** تلاش مجدد، وقفه زمانی در سمت شما که ما نتوانستیم از یک شکست
تشخیص دهیم، یک قطع شبکه — همه اینها ارسال دوباره تولید میکنند، و ارسال دوباره یک رفتار عادی است نه خطا.
`event_id` در همه تلاشها برای یک رویداد ثابت است. ثبتش کنید و دومی را رد کنید:
```js
async function handle(event) {
// یک قید یگانگی روی event_id تمام سازوکار است. انجام این بررسی بهصورت یک
// SELECT و بعد یک INSERT، رقابتی به جا میگذارد که دو تلاش همزمان پیدایش
// میکنند.
const inserted = await db
.insert(webhookEvents)
.values({ eventId: event.event_id, type: event.type })
.onConflictDoNothing()
.returning();
if (inserted.length === 0) return; // قبلاً پردازش شده
if (event.type !== "payment.completed") return;
await fulfil(event);
}
```
ایدمپوتنسی در سمت شما همان چیزی است که سیاست تلاش مجدد را ایمن میکند. بدون آن، یک اختلال کوچک یک سفارش
را دو بار ارسال میکند.
## محتوای وبهوک یک سرنخ است [#محتوای-وبهوک-یک-سرنخ-است]
بدنه، شناسهها و یک وضعیت را حمل میکند. عامدانه **هیچ مبلغی** حمل نمیکند، چون بدنه یک وبهوک چیزی است که
از شبکه به سرور شما میرسد، و مبلغی که بر پایهاش عمل میکنید باید از فراخوانیای بیاید که خودتان انجام
دادهاید.
پس الگوی هندلر همیشه یکی است: بررسی کن، تکراری را حذف کن، سپس بخوان.
```ts
const API = "https://my.coinlandexchange.com";
// The webhook payload is a HINT. This is the authority.
const payment = await fetch(`${API}/api/pay/v1/payments/${event.payment_id}`, {
headers: { Authorization: `Bearer ${process.env.COINLAND_PAY_KEY}` },
}).then((r) => r.json());
if (payment.reference_id !== order.id) return; // not this order
if (payment.currency !== order.currency) return; // not what we quoted
if (new Decimal(payment.amount).lt(order.total)) return; // underpaid
await fulfil(order, payment);
```
```js
const API = "https://my.coinlandexchange.com";
// The webhook payload is a HINT. This is the authority.
const payment = await fetch(`${API}/api/pay/v1/payments/${event.payment_id}`, {
headers: { Authorization: `Bearer ${process.env.COINLAND_PAY_KEY}` },
}).then((r) => r.json());
if (payment.reference_id !== order.id) return; // not this order
if (payment.currency !== order.currency) return; // not what we quoted
if (new Decimal(payment.amount).lt(order.total)) return; // underpaid
await fulfil(order, payment);
```
```python
from decimal import Decimal
import requests
API = "https://my.coinlandexchange.com"
# The webhook payload is a HINT. This is the authority.
payment = requests.get(
f"{API}/api/pay/v1/payments/{event['payment_id']}",
headers={"Authorization": f"Bearer {API_KEY}"},
timeout=15,
).json()
if payment["reference_id"] != order.id:
return # not this order
if payment["currency"] != order.currency:
return # not what we quoted
if Decimal(payment["amount"]) < order.total:
return # underpaid, do not ship
fulfil(order, payment)
```
```rust
const API: &str = "https://my.coinlandexchange.com";
// The webhook payload is a HINT. This is the authority.
let payment: Payment = http
.get(format!("{API}/api/pay/v1/payments/{}", event.payment_id))
.bearer_auth(&api_key)
.send()
.await?
.json()
.await?;
if payment.reference_id != order.id {
return Ok(()); // not this order
}
if payment.currency != order.currency {
return Ok(()); // not what we quoted
}
// Parse as a decimal type (rust_decimal), never f64.
if Decimal::from_str(&payment.amount)? < order.total {
return Ok(()); // underpaid, do not ship
}
fulfil(&order, &payment).await
```
```php
true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('COINLAND_PAY_KEY')],
]);
$payment = json_decode(curl_exec($ch), true);
curl_close($ch);
if ($payment['reference_id'] !== $order->id) { return; } // not this order
if ($payment['currency'] !== $order->currency) { return; } // not what we quoted
// bccomp keeps this out of float territory.
if (bccomp($payment['amount'], $order->total, 8) < 0) { return; } // underpaid
fulfil($order, $payment);
```
آن fetch تنها خواندن معتبر در کل این جریان است. `GET /payments/{id}` و `GET /sessions/{id}` دو فراخوانیاند
که میتوانید بر پاسخشان بنا کنید.
## سریع پاسخ بدهید، بعد کار کنید [#سریع-پاسخ-بدهید-بعد-کار-کنید]
بهمحض درست بودن امضا یک پاسخ ۲xx برگردانید. کار تحویل را بعد از پاسخ دادن انجام دهید، یا به یک صف
بسپارید.
هندلری که کالا را ارسال میکند، ایمیل میفرستد و انبار را بهروز میکند و بعد پاسخ میدهد، هندلری است که
دیر یا زود بیشتر از مهلت ارسال طول میکشد. آن وقت کار موفق میشود و ارسال بهعنوان شکست ثبت میشود، پس
دوباره تلاش میشود — و بررسی ایدمپوتنسی شما تنها چیزی است که میان یک سفارش و دو سفارش ایستاده است.
## تلاش مجدد و عقبنشینی [#تلاش-مجدد-و-عقبنشینی]
یک ارسال موفق است اگر با هر کد ۲xx پاسخ دهید. هر چیز دیگری — ۴xx، ۵xx، وقفه زمانی، خطای TLS، خطای DNS —
یک تلاش ناموفق است، و کوینلند با عقبنشینی نمایی و در حدود ۲۴ ساعت دوباره تلاش میکند و بعد دست میکشد.
چون ارسالها میتوانند ساعتها فاصله داشته باشند، **وبهوک را تنها مسیر خود نگیرید.** دو عادت ارزان،
یکپارچهسازی را در برابر وبهوکی که هرگز نمیرسد مقاوم میکند:
* **صفحه `return_url` شما استعلام میکند.** مشتری همانجا حاضر است و میداند پرداخت کرده، پس
`GET /sessions/{id}` را بخوانید و همان لحظه که `completed` گفت سفارش را پرداختشده نشان دهید.
* **یک بازبینی روزانه بقیه را میگیرد.** روزی یک بار سفارشهای پرداختنشده اخیر را با پرداختها مغایرتگیری
کنید. همین کار سفارشی را هم میگیرد که وقتی سرور شما خواب بود پرداخت شده است.
اگر تکراریها را حذف کنید، تلاش مجدد هیچ چیزی درباره درستی تغییر نمیدهد. تنها چیزی که تغییر میدهد این است
که یک ارسال ازدسترفته چقدر دیر میرسد.
## ثبت و چرخاندن کلید [#ثبت-و-چرخاندن-کلید]
نشانی و کلید مخفی خود را در کنسول کسبوکار تعیین کنید. الزامات:
* **فقط https.** نشانی وبهوک بدون رمزنگاری با `MERCHANT_WEBHOOK_URL_INVALID` رد میشود، و همینطور هر
نشانی غیرقابلتحلیل یا اشارهکننده به یک آدرس خصوصی.
* **از اینترنت عمومی قابل دسترسی.** ما نمیتوانیم به localhost یا آدرسی داخل VPN شما ارسال کنیم.
* **یک نشانی.** اگر لازم است رویداد را به چند سرویس پخش کنید، یک بار دریافت کنید و داخلی منتشر کنید.
چرخاندن کلید مخفی، چیزی را عوض میکند که هم وبهوکها و هم توکنهای رسید شما را امضا میکند، پس در بازه
کوتاهی که تغییر منتشر میشود هر دو مقدار قدیم و جدید را بپذیرید و بعد قدیمی را حذف کنید.
## فهرست بررسی [#فهرست-بررسی]
* بدنه خام، نه بدنه سریالایزشده دوباره.
* پنجره مهر زمانی اعمال شود، در هر دو جهت.
* مقایسه امضا در زمان ثابت.
* قید یگانگی روی `event_id`.
* پاسخ ۲xx پیش از شروع کار کند.
* `type` ناشناس نادیده گرفته شود، نه اینکه خطا بدهد.
* مبالغ از `GET /payments/{id}` خوانده شود، هرگز از محتوای وبهوک.
# ویجت (/fa/widget)
ویجت جایی است که مشتری واقعاً پرداخت میکند. تأیید پرداخت همیشه روی دامنه خودِ کوینلند و در نشانی
`https://my.coinlandexchange.com/pay/{sessionId}` انجام میشود؛ آنچه شما انتخاب میکنید این است که مشتری
چطور به آنجا برسد و چه مقدار از سفارش پیش از آن روی صفحه شما دیده شود.
هر سه از جلسه پرداختی شروع میشوند که از قبل روی سرور ساختهاید. ویجت خودش هرگز کلید API شما را نمیگیرد و
چیزی برای تنظیم در آن نیست — برندینگ، عنوان و قیمتهای شما همراه جلسه پرداخت میآیند.
این تصویر حالت **واردشده** را نشان میدهد. مشتریای که به کوینلند وارد نشده باشد همین سربرگ فروشنده و
خلاصه سفارش را میبیند با یک فرم ورود در زیر آن، و بعد از احراز هویت به همین صفحه میرسد. نام فروشنده،
شماره سفارش و شماره رسید در این تصویرها داده نمونهاند؛ چیدمان، قلم و رفتار همان صفحه واقعی است.
عنوان و توضیح سفارش، رشتههای خودِ فروشندهاند، پس در هر دو زبان دقیقاً همانطور نمایش داده میشوند که
فروشنده نوشته است.
**ویجت هیچ محاسبه پولیای انجام نمیدهد.** هر عددی که روی آن میبینید فیلدی است که به آن داده شده و
در لحظه ساخت جلسه تثبیت شده، و مبلغ روی دکمه پرداخت دقیقاً همان عددی است که دفتر حساب کسر میکند. هیچ
محاسبهای در سمت مرورگر وجود ندارد که بتواند با مبلغ کسرشده اختلاف پیدا کند، و هیچ چیزی روی صفحه
نمیتواند بین آنچه مشتری میخواند و آنچه میپردازد فاصله بیندازد.
آنچه مشتری میبیند به این بستگی دارد که کارمزد را چه کسی میپردازد. وقتی `fee_bearer` برابر
`"merchant"` باشد او یک عدد میبیند — مبلغ سفارش — چون کارمزد سهم شماست و چیزی به مبلغ او اضافه
نمیشود. وقتی برابر `"customer"` باشد، آن مبلغ اضافه بهعنوان یک سطر جداگانه نشان داده میشود، چون از
او خواسته میشود آن را بپردازد. نرخ پلتفرمِ شما در هیچکدام از این دو حالت روی این صفحه نیست.
## سه سطح یکپارچهسازی [#سه-سطح-یکپارچهسازی]
| سطح | چه مینویسید | مشتری چه میبیند | کِی مناسب است |
| ------------------------ | ------------------------ | ---------------------------- | --------------------------------------------------------- |
| **۱. هدایت میزبانیشده** | یک ۳۰۲ به `checkout_url` | صفحه شما، بعد صفحه کوینلند | کمترین کد را میخواهید، یا اصلاً جاواسکریپت اجرا نمیکنید |
| **۲. پنجره بازشو** | `CoinlandPay.open()` | صفحه شما، با کوینلند روی آن | میخواهید مشتری روی صفحه شما بماند |
| **۳. کارت درونصفحهای** | `CoinlandPay.mount()` | کارت سفارش **داخل** صفحه شما | میخواهید سفارش در صفحه پرداخت خودتان دیده شود |
هر سه یک اسکریپت، یک جلسه پرداخت و یک خروجی یکسان دارند، پس جابهجایی بینشان چند خط است. از سطح ۱ شروع
کنید و فقط وقتی بالاتر بروید که چیزی را که سطح بعدی اضافه میکند بخواهید.
## سطح ۱: هدایت میزبانیشده [#سطح-۱-هدایت-میزبانیشده]
بدون هیچ جاواسکریپتی. پاسخ ساخت جلسه پرداخت، `checkout_url` را دارد؛ یک ۳۰۲ به آن بفرستید.
```ts
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);
}
```
```js
const API = "https://my.coinlandexchange.com";
app.get("/checkout/:orderId", async (req, res) => {
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);
});
```
```python
import os
import requests
from flask import redirect
API = "https://my.coinlandexchange.com"
@app.get("/checkout/")
def start_checkout(order_id):
order = load_order(order_id)
session = requests.post(
f"{API}/api/pay/v1/sessions",
headers={
"Authorization": f"Bearer {os.environ['COINLAND_PAY_KEY']}",
"Content-Type": "application/json",
},
json={
"reference_id": order.id,
"title": f"Order {order.number}",
"amounts": [{"currency": "usdt", "amount": order.total_usdt}],
"return_url": "https://example.com/checkout/done",
"cancel_url": "https://example.com/cart",
},
timeout=15,
).json()
# Store the session id against the order BEFORE sending the customer away.
order.update(pay_session_id=session["id"])
return redirect(session["checkout_url"], code=302)
```
```rust
// axum handler. Cargo.toml: axum = "0.8",
// reqwest = { version = "0.12", features = ["json"] }, serde = { version = "1" }
use axum::response::Redirect;
const API: &str = "https://my.coinlandexchange.com";
pub async fn start_checkout(
state: AppState,
order_id: String,
) -> Result {
let order = state.load_order(&order_id).await?;
let session: Session = state
.http
.post(format!("{API}/api/pay/v1/sessions"))
.bearer_auth(&state.pay_key)
.json(&serde_json::json!({
"reference_id": order.id,
"title": format!("Order {}", order.number),
"amounts": [{ "currency": "usdt", "amount": order.total_usdt }],
"return_url": "https://example.com/checkout/done",
"cancel_url": "https://example.com/cart",
}))
.send()
.await?
.error_for_status()?
.json()
.await?;
// Store the session id against the order BEFORE sending the customer away.
state.attach_session(&order.id, &session.id).await?;
Ok(Redirect::to(&session.checkout_url))
}
```
```php
true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('COINLAND_PAY_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'reference_id' => $order->id,
'title' => 'Order ' . $order->number,
'amounts' => [['currency' => 'usdt', 'amount' => $order->total_usdt]],
'return_url' => 'https://example.com/checkout/done',
'cancel_url' => 'https://example.com/cart',
]),
]);
$session = json_decode(curl_exec($ch), true);
curl_close($ch);
// Store the session id against the order BEFORE sending the customer away.
$order->update(['pay_session_id' => $session['id']]);
header('Location: ' . $session['checkout_url'], true, 302);
exit;
```
مشتری پرداخت میکند، با `?receipt=&payment_id=` به `return_url` شما برمیگردد، و هندلر
وبهوک شما تحویل را انجام میدهد. با انتخاب این روش بهجای دو روش دیگر، تنها چیزی که از دست میرود ماندن
مشتری روی صفحه شماست.
## اسکریپت جایگذاری [#اسکریپت-جایگذاری]
سطح ۲ و ۳ به یک تگ اسکریپت نیاز دارند، بدون مرحله ساخت و بدون باندل:
```html
```
این اسکریپت تنها یک شیء جهانی به نام `CoinlandPay` تعریف میکند و هیچ چیز دیگری را بار نمیکند. آن را با
`defer` در `` یا در پایان `` قرار دهید.
## سطح ۲: پنجره بازشو [#سطح-۲-پنجره-بازشو]
```js
CoinlandPay.open({
sessionId: "3a7f21e8-9c04-4d6b-8e15-7b2a9f3c1d60",
onComplete({ payment_id, receipt_no, receipt }) {
// مشتری پرداخت کرد. رابط کاربری خودتان را جلو ببرید و بعد سمت سرور
// مغایرتگیری کنید.
window.location.href = `/checkout/done?payment_id=${payment_id}`;
},
onCancel() {
// پنجره بازشو بسته شد. جلسه پرداخت دستنخورده است و تا زمان انقضا باز
// میماند، پس فراخوانی دوباره open() با همان sessionId آن را از سر میگیرد.
},
});
```
`open()` بلافاصله برمیگردد. هیچ Promiseای با پرداخت resolve نمیشود، چون پنجره بازشو میتواند از صفحهای
که آن را باز کرده عمر بیشتری داشته باشد — مشتریای که پرداخت را بعد از بسته شدن تب شما تمام میکند، هم
پرداختش انجام میشود و هم وبهوک به شما میرسد.
چون `open()` وقتی پنجره بازشو مسدود باشد ممکن است صفحه را ترک کند (پایینتر را ببینید)، آن را آخرین کار
هندلرتان بگذارید. کاری را که باید حتماً اجرا شود بعد از آن در صف نگذارید.
## سطح ۳: کارت درونصفحهای [#سطح-۳-کارت-درونصفحهای]
`mount()` کارت سفارش را **داخل صفحه شما** رندر میکند: مشتری برندینگ، مبالغ، گزینههای ارز و شمارش معکوس را
بدون ترک صفحه پرداخت شما میبیند. این کارت یک iframe به کوینلند است و فقط نمایشی است — فشردن دکمه پرداخت
از قاب بیرون میزند و صفحه میزبانیشده را در یک پنجره بازشو باز میکند، دقیقاً مثل سطح ۲.
```html
```
به ظرف یک عرض بدهید و بگذارید کارت آن را پر کند؛ تا عرض گوشی واکنشگرا است. اگر جلسه پرداخت در لحظه
mount شدن از قبل `completed`، `expired` یا `canceled` باشد، کارت بهجای دکمه پرداخت همان وضعیت را نشان
میدهد.
ارزش دارد رابطه سه سطح را صریح بگوییم: **سطح ۳ شامل سطح ۲ است.** کارت درونصفحهای یک سطح نمایشی است، و
همان لحظه که مشتری تصمیم میگیرد، همان صفحه میزبانیشدهای را باز میکند که `open()` باز میکرد. اگر پنجره
بازشو مسدود شود، هر دو سطح به همان هدایت کامل صفحه برمیگردند و مشتری با
`?receipt=&payment_id=` به `return_url` شما بازمیگردد.
حتی اگر قصدتان فقط استفاده از پنجره بازشو یا کارت درونصفحهای باشد، مشتریای که پنجرههای بازشو را
غیرفعال کرده بهجای فعال شدن `onComplete` روی `return_url` فرود میآید. آن صفحه را طوری بسازید که
`payment_id` را بخواند، وضعیت سفارش را از سرور خودتان بپرسد، و اگر وبهوک هنوز نرسیده حالت «در انتظار»
نشان دهد.
## چرا پرداخت هرگز بهطور کامل داخل صفحه شما انجام نمیشود [#چرا-پرداخت-هرگز-بهطور-کامل-داخل-صفحه-شما-انجام-نمیشود]
کارت درونصفحهای در قاب قرار میگیرد؛ مرحله تأیید هرگز. صفحه پرداخت به نشانی
`https://my.coinlandexchange.com/pay/{sessionId}` هدر `X-Frame-Options: DENY` و `frame-ancestors
'none'` میفرستد، پس مرورگر از نمایش آن داخل سایت شما خودداری میکند. تنها مسیر نمایشیِ کارت که
`mount()` بار میکند در قاب قرار میگیرد. سه دلیل مستقل، که هر کدام بهتنهایی کافی است:
* **مشتری نمیتواند یک صفحه ورود داخل قاب را راستیآزمایی کند.** نوار نشانی دامنه شما را نشان میدهد در
حالی که فرم، اطلاعات ورود کوینلند و یک کد یکبارمصرف میخواهد. هیچ راهی نیست که آن قاب را از قابی که
خودتان کشیده باشید تشخیص دهد، و این دقیقاً شکل یک صفحه فیشینگ است — و مشتری را عادت میدهد اطلاعات
ورود کوینلند را در محیطی غیر از کوینلند تایپ کند. پنجره بازشو در تمام مدت احراز هویت نشانی
`my.coinlandexchange.com` را در نوار نشانی نگه میدارد، و این تنها نشانهای است که واقعاً از او
محافظت میکند.
* **صفحه میزبان میتواند یک قاب را طوری زیر نظر بگیرد که یک پنجره بازشو را نمیتواند.** فوکوس، زمانبندی
ضربههای کلید و چیدمان از سمت میزبان قابل مشاهدهاند، و گذاشتن یک لایه روی قاب همان حمله کلاسیک
clickjacking است: مشتری فکر میکند روی یک چیز کلیک میکند و چیز دیگری را تأیید میکند.
* **بههرحال کار نمیکرد.** مرورگرها اکنون کوکیهای شخص ثالث را بر اساس سایت میزبان تفکیک میکنند، پس یک
نشست کوینلند داخل صفحه شما همان نشستی نیست که مشتری از قبل دارد. از او خواسته میشد هر بار دوباره وارد
شود، آن هم در بیاعتمادترین جای ممکن.
**کارت درونصفحهای دقیقاً به این دلیل امن است که نمیتواند پولی جابهجا کند.** جلسهای را نمایش میدهد که
برای هر کسی که شناسهاش را دارد از قبل قابل مشاهده است، هیچ اطلاعات محرمانهای نمیگیرد، و هیچ اختیاری
برای تأیید چیزی ندارد. هر کاری که نیاز دارد مشتری هویتش را اثبات کند روی دامنه کوینلند انجام میشود، در
پنجرهای که نشانیاش را میتواند بخواند.
## مدیریت نتیجه [#مدیریت-نتیجه]
از هر سطحی که استفاده کنید، نتیجه به یک شکل میرسد.
این تابع در مرورگر مشتری اجرا میشود و هر کسی با یک کنسول باز میتواند آن را فعال کند. از آن برای جلو
بردن رابط کاربری خودتان استفاده کنید. تحویل سفارش را از [وبهوک](/webhooks) یا
`GET /payments/{id}` انجام دهید، که تنها دو چیزی هستند که مشتری نمیتواند در آنها دست ببرد.
یک مسیر بازگشتِ کند وجود دارد — مشتری پنجره پرداخت را پیش از آنکه گزارش بدهد بسته است — که در آن
`onComplete` با `payment_id`، `receipt_no` و `receipt` همه بهصورت **رشته خالی** صدا زده میشود و
تنها چیزی که معلوم است انجام شدن پرداخت است. هندلری که `receipt_no` را مستقیم روی صفحه میگذارد، جای
شماره رسید را خالی نشان میدهد و هیچ خطایی هم بالا نمیآید تا خبرتان کند.
پس کالبک را اینطور بگیرید: «چیزی تمام شد، برو بپرس» — پرداخت را با `reference_id` خودتان و از سمت
سرور با `GET /api/pay/v1/sessions/{reference_id}` بخوانید و از روی آن نمایش دهید. این همان انضباطی
است که کالبک از قبل برای تسویه میخواست؛ فقط برای فیلدها هم صادق است.
در پشت صحنه، پنجره بازشو با `postMessage` به `window.opener` گزارش میدهد و کارت درونصفحهای همان پیام را
از قاب خودش بازپخش میکند. بدنه پیام `{ source: "coinland-pay", type, sessionId, ... }` است، که در آن
`type` یکی از `payment_completed` یا `checkout_canceled` است. اگر خودتان پیامها را مدیریت میکنید — که
نیازی به آن نباید داشته باشید — تنها قاعدهای که واقعاً اهمیت دارد بررسی مبدأ است:
```js
window.addEventListener("message", (event) => {
// هرگز از این صرفنظر نکنید. بدون آن، هر صفحهای در هر تبی میتواند یک
// "payment_completed" جعلی برای شما بفرستد و رابط کاربری شما را پیش ببرد.
if (event.origin !== "https://my.coinlandexchange.com") return;
if (event.data?.source !== "coinland-pay") return;
// ...
});
```
اسکریپت همچنین بررسی میکند که پنجره پرداخت بسته شده یا نه. مشتریای که آن را بدون پرداخت میبندد هیچ
پیامی تولید نمیکند، پس همین بررسی دورهای بستهشدن پنجره است که آن را به `onCancel` تبدیل میکند.
## ظاهر و برندینگ [#ظاهر-و-برندینگ]
نمیتوانید ظاهر ویجت را تغییر دهید، چون نمیتوانید به درونش دست ببرید. آنچه میتوانید عوض کنید در کنسول
کسبوکار شماست: نام نمایشی، نام نمایشی فارسی و لوگو، که همه بالای عنوان سفارش نمایش داده میشوند. `title`
و `description` جلسه پرداخت هم برای هر سفارش در اختیار خودتان است.
ویجت بهصورت پیشفرض فارسی و راستبهچپ است و زبان دلخواه خودِ مشتری در کوینلند را دنبال میکند، نه زبان
صفحه شما.
## فهرست بررسی [#فهرست-بررسی]
* تگ اسکریپت به `https://my.coinlandexchange.com/pay/v1.js` اشاره میکند، نه به نسخهای که خودتان
میزبانی کردهاید.
* `return_url` و `cancel_url` هر دو https هستند و با بازدید مستقیم هم کار میکنند.
* صفحه `return_url` شما تحمل میکند که پیش از رسیدن وبهوک باز شود.
* هیچ چیزی در `onComplete` بهتنهایی دسترسی نمیدهد.
* هرگز تلاش نمیکنید خودِ صفحه پرداخت را در iframe بگذارید — تنها `mount()` چیزی را در قاب میگذارد، و
آنچه در قاب میگذارد کارت نمایشی است.
# پرداختها (/fa/concepts/payments)
پرداخت آن چیزی است که یک جلسه پرداخت تکمیلشده بهجا میگذارد: یک رکورد تغییرناپذیر از ارزشی که از یک حساب
کوینلند به حساب دیگر منتقل شده است. این شیء معتبرِ این API است و همان چیزی است که هر وبهوک به شما
میگوید بیایید و بخوانید.
## مدل انتقال داخلی [#مدل-انتقال-داخلی]
هر دو طرف یک پرداخت کوینلند پی حسابهای کوینلند هستند — حساب مشتری و حساب شما. پس پرداخت یک بدهکار و یک
بستانکار در دفتر حساب خودِ کوینلند است که با هم ثبت میشوند:
```text
┌──▶ fee_amount (Coinland)
payer ── amount ──────┤
└──▶ net_amount (merchant)
```
هیچ چیزی به بلاکچین نمیرسد. این چهار پیامد را دارد که ارزش دارد بر پایهشان طراحی کنید:
* **در یک مرحله تسویه میشود.** نه وضعیت در انتظار وجود دارد، نه شمارش تأییدیه، نه mempool. تا زمانی که
ویجت به مشتری بگوید پرداخت انجام شد، پول در موجودی شماست و `GET /payments/{id}` پاسخ میدهد.
* **در هیچ اندازهای کارمزد شبکه ندارد.** انتقال ۴ تتر همانقدر هزینه دارد که ۴٬۰۰۰ تتر، و همین پرداختهای
خرد را به شکلی ممکن میکند که یک ریل روی زنجیره نمیتواند.
* **قابل بازگشت نیست.** نه چارجبک وجود دارد و نه فراخوانی بازگشت در این API. بازپرداخت یک انتقال است که
خودتان و به تشخیص خودتان از موجودی خودتان میفرستید.
* **فقط پرداختهای تکمیلشده اینجا وجود دارند.** شیء «پرداخت در انتظار» برای استعلام وجود ندارد. جلسهای
که پرداخت نشده، `payment: null` دارد، و انتقال یا بهصورت اتمیک انجام میشود یا هرگز انجام نمیشود.
پرداختها به یک **کیفپول کسبوکارِ اختصاصی** واریز میشوند که از موجودی اسپات و معاملاتی شخصی شما جدا
نگه داشته میشود، تا درآمد یک کسبوکار با پول شخصی قاطی نشود. برای بررسی رسیدن یک پرداخت، همین کیفپول
جایی است که باید نگاه کنید.
## بیرون بردن درآمدتان [#بیرون-بردن-درآمدتان]
کیفپول کسبوکار آنچه دریافت کردهاید را نگه میدارد؛ خودش مقصد برداشت نیست. بیرون بردن پول دو مرحله
دارد و مرحله دوم همان کاری است که همیشه میکردید:
**از کسبوکار به کیفپول اصلی انتقال دهید**، در کنسول کسبوکار. این جابهجایی بین دو دفتر حساب خودِ
ماست، پس **فوری و بدون کارمزد** است — نه کارمزد شبکهای، نه صرافی بیرونی، نه انتظار.
**از کیفپول اصلی معامله یا برداشت کنید**، دقیقاً مثل قبل. هیچ چیزی در آن مسیر تغییر نکرده است.
نمیتوانید مستقیماً از کیفپول کسبوکار برداشت کنید، و این نبودن طراحی است نه کمبود: به این ترتیب یک
مسیر برداشت باقی میماند، همان که میشناسید، بهجای اینکه یک مقصد برداشت دوم با قواعد خودش اضافه شود.
اول انتقال دهید، بعد برداشت کنید.
## مبلغ، کارمزد و مبلغ خالص [#مبلغ-کارمزد-و-مبلغ-خالص]
اگر تا امروز بر پایه `amount` مغایرتگیری میکردهاید، این را بخوانید. `amount` عددی است که **شما
قیمت گذاشتهاید**. آنچه واقعاً از مشتری کسر شده `charged_amount` است، و وقتی
`fee_bearer` برابر `"customer"` باشد بزرگتر است. برای دفتر حساب خودتان همیشه `net_amount` فیلد
درست بوده و هست؛ برای «مشتری من چقدر پرداخت کرد» به `charged_amount` سوئیچ کنید.
چهار عدد بههمراه پرچمی که آنها را به هم پیوند میدهد، و اشتباه گرفتنشان رایجترین باگ مغایرتگیری در هر ریل پرداختی است:
## کارمزد را چه کسی میپردازد [#کارمزد-را-چه-کسی-میپردازد]
برای هر کسبوکار خودتان انتخاب میکنید که کارمزد کوینلند را جذب کنید یا به مبلغ مشتری اضافه شود. یک
اتحاد در **هر دو** حالت برقرار است و کل مدل حسابداری همین است:
```text
charged_amount - net_amount == fee_amount
```
| `fee_bearer` | از مشتری کسر میشود | شما دریافت میکنید |
| ------------ | --------------------------------------- | ----------------------------------- |
| `merchant` | `charged_amount == amount` | `net_amount == amount - fee_amount` |
| `customer` | `charged_amount == amount + fee_amount` | `net_amount == amount` |
پس مبالغ سفارش را با **`amount`**، دفتر حساب خودتان را با **`net_amount`**، و هر پرسشی از جنس «مشتری
چقدر پرداخت کرد» را با **`charged_amount`** مغایرتگیری کنید. استفاده از یک فیلد برای هر سه، همان
چیزی است که دفتر حساب را دچار انحراف میکند.
## نرخ کارمزد شما از کجا میآید [#نرخ-کارمزد-شما-از-کجا-میآید]
نرخ شما عدد ثابتی نیست و خودتان نمیتوانید تعیینش کنید. از یک **نردبان پلکانی** میآید که بر پایه حجم
و تعداد پرداختهای ۳۰ روز اخیر شما بهصورت خودکار جابهجا میشود — هرچه بیشتر معامله کنید، خودبهخود
پله پایینتر میروید. پله فعلی و پیشرفت شما تا پله بعدی در کنسول کسبوکار نمایش داده میشود.
### نرخ شما در لحظه ساخت جلسه تثبیت میشود [#نرخ-شما-در-لحظه-ساخت-جلسه-تثبیت-میشود]
نرخ مؤثر و اینکه کارمزد را چه کسی میپردازد، **در لحظه ساخت جلسه پرداخت** روی همان جلسه تثبیت میشوند
و تأیید نهایی بر همان تصویر ثبتشده تسویه میکند. نتیجهای که باید بر پایهاش طراحی کنید:
> تغییر نرخ روی جلسه **بعدی** شما اثر میگذارد، نه روی جلسهای که از قبل باز است.
این مهم است چون نردبان خودش و در یک بازبینی شبانه جابهجا میشود، در حالی که یک جلسه میتواند تا ۲۴
ساعت زنده بماند. بدون این تثبیت، مبلغی که در ویجت نمایش داده شده و مبلغی که در نهایت کسر میشود
میتوانستند با هم نخوانند. با آن، مشتریای که به یک پرداخت باز نگاه میکند دقیقاً همان شرایطی را
میپردازد که به او اعلام شده بود.
این همان تضمینی است که قیمتهای ارزی در یک [جلسه دلاری](/concepts/sessions) از قبل دارند، در همان
بازه — مهلت خودِ جلسه (`expires_at`). یک مهلت، نه دو تا.
چون شرایط پیش از جابهجا شدن پول تثبیت شدهاند، تغییر نرخ گذشته را هم بازنویسی نمیکند: `fee_amount`
یک پرداخت قدیمی برای همیشه قطعی است و گزارشی که ماه پیش گرفتهاید امروز هم همان پاسخ را میدهد.
هر چهار مبلغ رشته اعشاریاند و باید تا رسیدن به پایگاه داده شما رشته بمانند. آنها را با یک نوع اعشاری دقیق پارس
کنید، هرگز با عدد شناور — بخش [مدیریت مبالغ](/go-live#money-handling) در آماده انتشار را ببینید.
## نگه داشتن فقط استیبلکوین [#نگه-داشتن-فقط-استیبلکوین]
میتوانید برای هر کسبوکار انتخاب کنید که هر ارزی دریافت میکنید **بهصورت خودکار به تتر (USDT)
فروخته شود**، تا موجودیتان در یک دارایی بماند و هر ارزی که مشتریانتان پرداخت کردهاند روی هم انباشته
نشود. این قابلیت بهصورت پیشفرض خاموش است و خودتان در کنسول کسبوکار روشنش میکنید. در حال حاضر تتر
تنها مقصد مجاز است — این فهرست را اپراتور تعیین میکند، نه شما.
پس از تسویه هر پرداخت چه اتفاقی میافتد:
* کوینلند `net_amount` همان پرداخت را از کیفپول کسبوکار به کیفپول اسپات شما منتقل میکند، آنجا یک
**سفارش فروش بازار (اسپات)** معمولی از طرف شما ثبت میکند، و حاصل را به کیفپول کسبوکار برمیگرداند.
* این کار در یک بازبینی دورهای و کمی بعد از تسویه انجام میشود و فروش بهصورت غیرهمگام تسویه میشود —
پس پیش از نشستن تتر در کیفپول کسبوکار یک حالت «در جریان» وجود دارد. **نه بلافاصله** است و نه در
بازهای تضمینشده.
با **قیمت لحظهای بازار** اجرا میشود و **کارمزد معمول معاملات اسپات** را میپردازد. نه رایگان است و
نه نرخش قفل شده: [قیمتهای یک جلسه دلاری](/concepts/sessions) و
[شرایط کارمزد شما](#نرخ-شما-در-لحظه-ساخت-جلسه-تثبیت-میشود) هر دو در لحظه ساخت جلسه تثبیت میشوند،
اما این فروش بعد از آن و با نرخ همان لحظه بازار انجام میشود. هیچ بخشی از تبدیل از پیش اعلام
نمیشود.
سه حالت اصلاً تبدیل نمیشوند و در هر سه، ارز بهسادگی در موجودی شما میماند:
| حالت | چه میشود |
| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------- |
| پرداخت از ابتدا با همان ارز مقصد شما رسیده | چیزی برای فروش نیست |
| مبلغ کمتر از حداقل بازار است | بیدرنگ بهعنوان خردهمانده میماند — انتظار کمکی نمیکند |
| بازار در دسترس نیست یا فروش شکست میخورد | هر ۱۵ دقیقه، تا ۵ بار تلاش میشود و بعد برای همیشه رها میشود — و ارز به کیفپول کسبوکار شما برگردانده میشود |
این همان نکتهای است که باید با خود ببرید. پرداخت در همان لحظه تسویه قطعی است و **درستی یک پرداخت
هرگز به باز بودن بازار وابسته نیست**. تبدیلی که رد شود، شکست بخورد یا اصلاً اجرا نشود، پرداخت را
`completed` و پول را مال شما باقی میگذارد — فقط همان ارز اولیه را نگه میدارید. نه بازگشتی وجود
دارد و نه کسر بعدی.
هیچ اعلانی هم در هیچ حالتی برایتان فرستاده نمیشود: نه ایمیلی هست و نه وبهوکی برای تبدیل. نتیجه و
دلیل آن (`already-stable`، `below-min`، `market-unavailable`، `target-invalid`،
`attempts-exhausted`) در کنسول کسبوکار ثبت میشود، و اگر ارزی که انتظار داشتید تبدیل شود هنوز در
موجودیتان مانده، همانجا را نگاه کنید.
آستانه خردهمانده همان حداقل بازارِ آن جفتارز است و **در هیچ سطح رو به کسبوکاری منتشر نمیشود** — از
پیش نمیتوانید حساب کنید که یک پرداخت مشخص تبدیل خواهد شد یا نه. آنچه به دست میآورید نتیجه است، که با
`below-min` ثبت میشود.
چون این فروش یک سفارش معمولی در حساب خودتان است، در تاریخچه معاملات عادی شما و کنار هر معامله دیگری
دیده میشود، و کارمزدش هم همانجا قابل مغایرتگیری است. هر رکورد تبدیل، `order_id` معاملهای را هم که
به آن تبدیل شده همراه دارد، پس میتوانید یک پرداخت مشخص را به یک معامله مشخص وصل کنید. توجه کنید که
این کارمزد، کارمزد **معامله** است و کاملاً از `fee_amount` جداست، که کارمزد کوینلند روی خودِ پرداخت
است.
این تنظیم در کنسول کسبوکار انجام و خوانده میشود و عامداً بخشی از API کسبوکار **نیست**: نه
`GET /me` و نه شیء پرداخت آن را گزارش میکنند، چون تبدیل چیزی است که بعداً برای موجودی شما رخ میدهد،
نه خاصیتی از خودِ پرداخت.
## شماره رسید [#شماره-رسید]
هر پرداخت در کنار شناسه یکتای خود یک شماره رسید خوانا هم میگیرد:
```text
CLP-8F3K2M9Q
```
این شماره برای گفتن با صدای بلند و تایپ دستی طراحی شده است: چیزی است که مشتری در ایمیل پشتیبانی نقل
میکند و کارشناس شما در کادر جستوجو میچسباند. برخلاف شناسه یکتا، کوتاه است و از این مسیر سالم بیرون
میآید.
همچنین یک **شناسه جستوجو** است. `GET /api/pay/v1/payments/{id}` هر دو را میپذیرد:
```bash
# با شناسه یکتا
curl .../api/pay/v1/payments/b92e4d17-6c38-4a05-9f2b-1e7d3c8a5049 \
-H "Authorization: Bearer $COINLAND_PAY_KEY"
# با شماره رسید — همان رکورد
curl .../api/pay/v1/payments/CLP-8F3K2M9Q \
-H "Authorization: Bearer $COINLAND_PAY_KEY"
```
شماره رسید محرمانه نیست و اطلاعات محرمانه هم نیست. دانستن آن بهتنهایی چیزی را اثبات نمیکند، و
[توکن رسید](/concepts/receipts) امضاشده برای همین کار وجود دارد.
## فهرست کردن پرداختها [#فهرست-کردن-پرداختها]
`GET /api/pay/v1/payments` پرداختهای شما را از جدید به قدیم و با صفحهبندی مکاننما برمیگرداند:
```bash
curl "https://my.coinlandexchange.com/api/pay/v1/payments?limit=100¤cy=usdt&from=2026-08-01T00:00:00Z" \
-H "Authorization: Bearer $COINLAND_PAY_KEY"
```
```json
{
"data": [
{
"id": "b92e4d17-6c38-4a05-9f2b-1e7d3c8a5049",
"receipt_no": "CLP-8F3K2M9Q",
"session_id": "3a7f21e8-9c04-4d6b-8e15-7b2a9f3c1d60",
"reference_id": "order-10492",
"status": "completed",
"currency": "usdt",
"amount": "24.90",
"charged_amount": "24.90",
"fee_amount": "0.12",
"fee_bearer": "merchant",
"net_amount": "24.78",
"metadata": { "cart_id": "c_88213" },
"receipt": "v1.eyJwYXltZW50X2lkIjoi...",
"paid_at": "2026-08-11T12:04:31Z"
}
],
"next_cursor": "eyJwYWlkX2F0IjoiMjAyNi0wOC0xMVQxMjowNDozMVoifQ"
}
```
با دنبال کردن `next_cursor` صفحهها را بخوانید تا وقتی که `null` برگردد. صفحهها را نشمارید و اندازه صفحه
را فرض نگیرید: همان مکاننمایی را که به شما داده شده بفرستید، و وقتی مکاننمایی نبود متوقف شوید.
`from` و `to` بازه `paid_at` را محدود میکنند و `currency` به یک ارز فیلتر میکند. با هم همان چیزی هستند
که با آن گزارش تسویه روزانه میسازید:
```ts
const API = "https://my.coinlandexchange.com";
export async function* allPayments(params: Record) {
let cursor: string | null = null;
do {
const query = new URLSearchParams({ ...params, limit: "100" });
if (cursor) query.set("cursor", cursor);
const page = await fetch(`${API}/api/pay/v1/payments?${query}`, {
headers: { Authorization: `Bearer ${process.env.COINLAND_PAY_KEY}` },
}).then((r) => r.json());
yield* page.data;
cursor = page.next_cursor; // stop when it comes back null
} while (cursor);
}
```
```js
const API = "https://my.coinlandexchange.com";
async function* allPayments(params) {
let cursor = null;
do {
const query = new URLSearchParams({ ...params, limit: "100" });
if (cursor) query.set("cursor", cursor);
const page = await fetch(`${API}/api/pay/v1/payments?${query}`, {
headers: { Authorization: `Bearer ${process.env.COINLAND_PAY_KEY}` },
}).then((r) => r.json());
yield* page.data;
cursor = page.next_cursor; // stop when it comes back null
} while (cursor);
}
```
```python
import os
import requests
API = "https://my.coinlandexchange.com"
AUTH = {"Authorization": f"Bearer {os.environ['COINLAND_PAY_KEY']}"}
def all_payments(**params):
cursor = None
while True:
query = {**params, "limit": 100}
if cursor:
query["cursor"] = cursor
page = requests.get(
f"{API}/api/pay/v1/payments", params=query, headers=AUTH, timeout=30
).json()
yield from page["data"]
cursor = page.get("next_cursor")
if not cursor: # stop when it comes back null
break
```
```rust
const API: &str = "https://my.coinlandexchange.com";
#[derive(serde::Deserialize)]
struct Page {
data: Vec,
next_cursor: Option,
}
pub async fn all_payments(
http: &reqwest::Client,
api_key: &str,
currency: &str,
) -> reqwest::Result> {
let mut out = Vec::new();
let mut cursor: Option = None;
loop {
let mut req = http
.get(format!("{API}/api/pay/v1/payments"))
.bearer_auth(api_key)
.query(&[("limit", "100"), ("currency", currency)]);
if let Some(c) = &cursor {
req = req.query(&[("cursor", c)]);
}
let page: Page = req.send().await?.error_for_status()?.json().await?;
out.extend(page.data);
// Stop when it comes back null.
match page.next_cursor {
Some(c) => cursor = Some(c),
None => break,
}
}
Ok(out)
}
```
```php
100]);
if ($cursor !== null) {
$query['cursor'] = $cursor;
}
$ch = curl_init($api . '/api/pay/v1/payments?' . http_build_query($query));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => $auth,
]);
$page = json_decode(curl_exec($ch), true);
curl_close($ch);
yield from $page['data'];
$cursor = $page['next_cursor'] ?? null; // stop when it comes back null
} while ($cursor !== null);
}
```
## مغایرتگیری [#مغایرتگیری]
پرداختها `reference_id` شما را همراه دارند، پس مغایرتگیری به هیچ شناسهای که خودتان انتخاب نکردهاید نیاز
ندارد:
1. پرداختهای یک روز را با `from` و `to` بخوانید.
2. هر `reference_id` را به یک سفارش در سیستم خودتان وصل کنید.
3. بررسی کنید `amount` با آنچه برای آن سفارش در آن ارز مطالبه کردهاید یکی است.
4. `net_amount` را به تفکیک ارز جمع بزنید و با بستانکاریهای **کیفپول کسبوکار** خود مقایسه کنید.
سفارشی که پرداختی ندارد هرگز پرداخت نشده است. پرداختی که سفارشی ندارد همان موردی است که باید بررسی شود، و
تقریباً همیشه یعنی یک `reference_id` دوباره استفاده شده یا جایی تولید شده که انتظارش را نداشتهاید.
# رسیدها (/fa/concepts/receipts)
هر پرداخت تکمیلشده یک **توکن رسید** دارد: رشتهای کوتاه و خودبسنده که پرداخت را اثبات میکند، بر پایه
شرطی که فقط شما میتوانید بررسی کنید. این پاسخ همان وضعیتی است که «مشتری میگوید پرداخت کردهام و من هیچ
راهی برای دانستنش ندارم».
```text
v1.eyJwYXltZW50X2lkIjoiYjkyZTRkMTctNmMzOC00YTA1LTlmMmItMWU3ZDNjOGE1MDQ5Iiwic
mVjZWlwdF9ubyI6IkNMUC04RjNLMk05USIsIm1lcmNoYW50X2lkIjoiOGYxYzlhMzQtM2QyZS00Y
jE3LTlmMGEtMmM2ZDViOGU0YTcxIiwicmVmZXJlbmNlX2lkIjoib3JkZXItMTA0OTIiLCJjdXJyZ
W5jeSI6InVzZHQiLCJhbW91bnQiOiIyNC45MCIsImNoYXJnZWRfYW1vdW50IjoiMjQuOTAiLCJmZ
WVfYmVhcmVyIjoibWVyY2hhbnQiLCJuZXRfYW1vdW50IjoiMjQuNzgiLCJwYWlkX2F0IjoiMjAyN
i0wOC0xMVQxMjowNDozMVoifQ.k7Qw3xR2mB9pLd4vN8sYc1TfHj0aXeU6ZgO5rWq
```
توکن را در سه جا دریافت میکنید: روی شیء `payment` با نام `receipt`، افزوده به `return_url` شما بهصورت
`?receipt=...`، و درون همان توکنی که مشتری میتواند از تاریخچه پرداختهای خودش کپی کند.
## قالب [#قالب]
سه بخش جدا شده با نقطه، که بخش میانی تمام محتوا است:
```text
v1..
```
* **`v1`** نسخه قالب است. توکنی که بخش اول آن را نمیشناسید رد کنید، بهجای اینکه حدس بزنید.
* **محتوا** یک JSON ساده است که با base64url و بدون padding کدگذاری شده. رمزنگاری نشده — هر کسی میتواند
آن را بخواند. این عامدانه است: توکن یک اثبات است، نه یک راز.
* **امضا** یک HMAC-SHA256 روی رشته دقیقِ `"v1." + <بخش دوم>` است، با کلید **مخفی وبهوک** شما، و سپس
کدگذاریشده با base64url.
به آنچه امضا میشود دقت کنید: دو بخش اول به هم چسبیده، از جمله پیشوند `v1.` و نقطه. امضای تنهای محتوا به
کسی اجازه میداد یک امضای معتبر را روی نسخهای دیگر از قالب جابهجا کند.
### فیلدهای محتوا [#فیلدهای-محتوا]
محتوا همانطور که سریالایز شده امضا میشود، پس هر کسی که آن را از اجزا بازمیسازد باید کلیدها را با
همین ترتیب قانونی بنویسد: `payment_id`، `receipt_no`، `merchant_id`، `reference_id`، `currency`،
`amount`، `charged_amount`، `fee_bearer`، `net_amount`، `paid_at`. برای اعتبارسنجی توکنی که به شما
**داده** شده هیچکدام از اینها لازم نیست — رشته را همانطور که رسیده هش میکنید.
هیچ فیلد انقضایی وجود ندارد. رسید ثبت چیزی است که اتفاق افتاده، و همانطور درست میمانَد؛ اگر به تازگی
زمان اهمیت دارد، خودتان `paid_at` را مقایسه کنید.
## چرا قابل جعل نیست [#چرا-قابل-جعل-نیست]
امضا یک HMAC است با کلید مخفی وبهوک شما، و آن کلید دقیقاً در دو جا وجود دارد: در انبار رمزشده کوینلند و
روی سرور شما. هرگز به مشتری نشان داده نمیشود، هرگز به مرورگر فرستاده نمیشود، و هرگز بخشی از توکن نیست.
پس یک مشتری — یا هر کسی که صد رسید معتبر دیده باشد — میتواند محتوا را رمزگشایی کند، `amount` را به عددی
بزرگتر تغییر دهد و دوباره کدگذاری کند. کاری که نمیتواند بکند تولید امضایی است که با محتوای تغییریافته
بخواند، چون محاسبه آن به کلید نیاز دارد. کد اعتبارسنجی شما آن را در همان خطی رد میکند که یک رسید واقعی را
میپذیرفت.
این همان ساختاری است که [امضای وبهوک](/webhooks) دارد، با همان کلید، یعنی یک کلید برای چرخاندن و یک
تابع رمزنگاری برای درست پیاده کردن.
## اعتبارسنجی آفلاین [#اعتبارسنجی-آفلاین]
آفلاین مسیر پیشنهادی است. چند خط کد با کتابخانه استاندارد است، هیچ رفتوبرگشت شبکهای ندارد، و وقتی
کوینلند در دسترس نباشد هم کار میکند.
```ts
import crypto from "node:crypto";
/**
* Returns the payload if the token is authentic, or null.
* `secret` is your webhook secret.
*/
export function verifyReceipt(
token: string,
secret: string,
expectedMerchantId: string,
): Record | null {
const parts = token.split(".");
if (parts.length !== 3 || parts[0] !== "v1") return null;
const [version, body, signature] = parts;
const expected = crypto
.createHmac("sha256", secret)
.update(`${version}.${body}`)
.digest("base64url");
// Constant-time: a comparison that returns early leaks how much of a guess
// was right, which is enough to reconstruct a signature.
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(signature, "utf8");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
const payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
// A signature only proves the token came from a Coinland secret. Checking the
// merchant id is what proves it came from YOURS.
if (payload.merchant_id !== expectedMerchantId) return null;
return payload;
}
```
```js
const crypto = require("node:crypto");
/** Returns the payload if the token is authentic, or null. */
function verifyReceipt(token, secret, expectedMerchantId) {
const parts = token.split(".");
if (parts.length !== 3 || parts[0] !== "v1") return null;
const [version, body, signature] = parts;
const expected = crypto
.createHmac("sha256", secret)
.update(`${version}.${body}`)
.digest("base64url");
// Constant-time comparison, never ===.
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(signature, "utf8");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
const payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
if (payload.merchant_id !== expectedMerchantId) return null;
return payload;
}
module.exports = { verifyReceipt };
```
```python
import base64
import hashlib
import hmac
import json
def _b64url_decode(value: str) -> bytes:
# base64url with the padding stripped: put it back before decoding.
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
def verify_receipt(token: str, secret: str, expected_merchant_id: str):
"""Returns the payload dict if the token is authentic, or None."""
parts = token.split(".")
if len(parts) != 3 or parts[0] != "v1":
return None
version, body, signature = parts
raw = hmac.new(
secret.encode(), f"{version}.{body}".encode(), hashlib.sha256
).digest()
expected = base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
# compare_digest is the constant-time comparison. Never use ==.
if not hmac.compare_digest(expected, signature):
return None
payload = json.loads(_b64url_decode(body))
# A valid signature proves SOME Coinland secret signed it; the merchant id
# is what proves it was yours.
if payload.get("merchant_id") != expected_merchant_id:
return None
return payload
```
```rust
// Cargo.toml: hmac = "0.12", sha2 = "0.10", base64 = "0.22", serde_json = "1"
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac;
/// Returns the payload if the token is authentic, or None.
pub fn verify_receipt(
token: &str,
secret: &str,
expected_merchant_id: &str,
) -> Option {
let mut parts = token.split('.');
let (version, body, signature) = (parts.next()?, parts.next()?, parts.next()?);
if parts.next().is_some() || version != "v1" {
return None;
}
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).ok()?;
mac.update(version.as_bytes());
mac.update(b".");
mac.update(body.as_bytes());
// verify_slice IS the constant-time compare — do not decode to a String
// and use ==.
let provided = URL_SAFE_NO_PAD.decode(signature).ok()?;
mac.verify_slice(&provided).ok()?;
let payload: serde_json::Value =
serde_json::from_slice(&URL_SAFE_NO_PAD.decode(body).ok()?).ok()?;
// The signature proves a Coinland secret signed it; the merchant id proves
// it was yours.
if payload.get("merchant_id")?.as_str()? != expected_merchant_id {
return None;
}
Some(payload)
}
```
```php
سه چیز که در هر زبانی باید درست انجام شوند:
1. **base64url، نه base64.** کاراکترهای `-` و `_` جای `+` و `/` را میگیرند و padding با `=` حذف میشود.
یک رمزگشای base64 معمولی روی بعضی توکنها شکست میخورد و روی بعضی موفق میشود، که بدترین نوع باگ برای
داشتن در یک مسیر پرداخت است.
2. **مقایسه زمانثابت.** `crypto.timingSafeEqual`، `hash_equals`، `hmac.compare_digest` — هر نامی که
کتابخانه استاندارد شما دارد. یک `==` با خروج زودهنگام روی امضا یک ضعف واقعی و بهرهبرداریشده است، نه
نظری.
3. **`merchant_id` را بررسی کنید.** امضای معتبر اثبات میکند توکن با یکی از کلیدهای وبهوک کوینلند ساخته
شده. مقایسه شناسه کسبوکار است که اثبات میکند با کلید شما ساخته شده و نه کلید کسبوکار دیگری.
## اعتبارسنجی از طریق API [#اعتبارسنجی-از-طریق-api]
اگر ترجیح میدهید HMAC را خودتان پیاده نکنید، `POST /api/pay/v1/receipts/verify` این کار را برایتان انجام
میدهد. این اندپوینت هم امضا را با کلید شما بررسی میکند **و هم** اینکه پرداختی با همان مشخصات هنوز وجود
دارد، پس چیزی را میگیرد که اعتبارسنجی آفلاین نمیتواند: یک توکن درستامضاشده برای پرداختی که بعداً معلوم
شد چیزی جز آنچه ادعا میکرد بوده است.
```bash
curl -X POST https://my.coinlandexchange.com/api/pay/v1/receipts/verify \
-H "Authorization: Bearer $COINLAND_PAY_KEY" \
-H "Content-Type: application/json" \
-d '{"receipt":"v1.eyJwYXltZW50X2lkIjoi....k7Qw3xR2mB9pLd4vN8sYc1TfHj0aXeU6ZgO5rWq"}'
```
```json title="۲۰۰ موفق"
{
"valid": true,
"payment": {
"id": "b92e4d17-6c38-4a05-9f2b-1e7d3c8a5049",
"receipt_no": "CLP-8F3K2M9Q",
"reference_id": "order-10492",
"currency": "usdt",
"amount": "24.90",
"charged_amount": "24.90",
"fee_bearer": "merchant",
"net_amount": "24.78",
"paid_at": "2026-08-11T12:04:31Z"
}
}
```
امضای نامعتبر، محتوای تغییریافته و پرداخت ناشناس همه با `PAY_RECEIPT_INVALID` (۴۲۲) پاسخ میگیرند — یک کد،
چون راهحل در هر سه حالت یکی است و تفکیک آنها به یک مهاجم میگفت کدام نیمه از جعلش داشته کار میکرده.
این اندپوینت یک امکان راحت است، نه مرجع. همان رکوردهایی را میخواند که اعتبارسنجی آفلاین دربارهشان استدلال
میکند، پس توکنی که آفلاین با کلید خودتان تأیید شود همین حالا اثبات شده است؛ فراخوانی شبکه بررسی وجود را
اضافه میکند، نه اعتماد را.
## اثبات، تحویل نیست [#اثبات-تحویل-نیست]
یک رسید تأییدشده به شما میگوید پرداختی انجام شده است. به شما نمیگوید که این سفارش قبلاً تحویل نشده، و
توسط هر کسی که آن را در دست دارد ارائه میشود. کالا را از هندلر [وبهوک](/webhooks) یا از
`GET /payments/{id}` آزاد کنید، بر پایه وضعیت سفارش در سیستم خودتان.
این تفکیک مهم است چون رسید عامدانه قابلحمل است. همان توکن میتواند دو بار نشان داده شود، یا توسط کسی که
مشتری آن را برایش فرستاده. هیچکدام نقص نیست — همین است که رسید را مفید میکند — اما یعنی توکن به «آیا این
پرداخت شد؟» پاسخ میدهد و هرگز به «آیا باید ارسال کنم؟».
کاربردهای درست یک رسید:
* **پشتیبانی.** مشتری توکنش را میچسباند؛ کارشناس شما با یک فراخوانی تابع آن را تأیید میکند و بیدرنگ
میداند باید حرفش را باور کند یا نه، بدون هیچ استعلامی از کوینلند.
* **سیستمهای پاییندستی.** یک سرویس تحویل یا یک شریک میتواند پرداخت را بررسی کند بدون آنکه کلید API شما را
داشته باشد، چون اعتبارسنجی فقط به کلید مخفی وبهوک نیاز دارد و به شبکه نیازی ندارد.
* **سوابق.** توکن را همراه سفارش ذخیره کنید. سالها بعد هم اثبات میکند چه چیزی پرداخت شده، حتی اگر API
جلو رفته باشد.
کاری که یک رسید هرگز نباید باشد:
* چیزی که صفحه `return_url` شما برای پرداختشده علامت زدن سفارش به آن اعتماد کند.
* توکن دسترسی برای هر چیزی. رسید هیچ اجازهای نمیدهد؛ گواهی میدهد.
# جلسههای پرداخت (/fa/concepts/sessions)
جلسه پرداخت یک پیشنهاد است به یک مشتری: این سفارش، با این قیمتها، تا این مهلت. شما میسازیدش، مشتری
پرداختش میکند، و دقیقاً در یکی از سه وضعیت پایان مییابد.
همه چیز درباره یک جلسه پرداخت در لحظه ساخت تعیین میشود. فراخوانی برای ویرایش وجود ندارد — اگر قیمت عوض
شد، جلسه را لغو کنید و یکی تازه با `reference_id` جدید بسازید.
## چرخه حیات [#چرخه-حیات]
```text
┌──▶ completed
│
open ──────┼──▶ expired
│
└──▶ canceled
```
* **`open`** تنها وضعیتی است که ویجت در آن پرداخت میگیرد. مشتری میتواند در طول این مدت هر چند بار که
بخواهد صفحه را باز کند و رهایش کند.
* **`completed`** پایانی است، و جلسه پرداخت از آن پس شیء `payment` خود را همراه دارد. پولی که جابهجا شده
جابهجا میمانَد: در این ریل نه لغو وجود دارد، نه بازگشت، نه دریافت جزئی. اگر لازم است پول را برگردانید،
برای مشتری یک انتقال بفرستید.
* **`expired`** خودش در `expires_at` رخ میدهد. چیزی رزرو نشده و چیزی جابهجا نشده، پس یک جلسه منقضی برای
هیچکس هزینهای ندارد.
* **`canceled`** یعنی شما تصمیم گرفتهاید سفارش پیش از پرداخت منتفی است. لغو ایدمپوتنت است: لغو یک جلسه
لغوشده همان جلسه را بدون تغییر برمیگرداند و خطا نمیدهد. لغو یک جلسه تکمیلشده با `PAY_SESSION_STATE`
(۴۰۹) رد میشود.
هرگز لازم نیست برای انقضا استعلام دورهای بزنید. کوینلند وبهوک `session.expired` را میفرستد و
`GET /api/pay/v1/sessions/{id}` همیشه وضعیت فعلی را گزارش میکند.
### مدت اعتبار جلسه [#مدت-اعتبار-جلسه]
مدت اعتبار پیشفرض از کنسول کسبوکار شما میآید. برای هر جلسه میتوانید با `ttl_minutes` بین ۵ و ۱۴۴۰
(۲۴ ساعت) آن را بازنویسی کنید.
آن را متناسب با چیزی که میفروشید انتخاب کنید. TTL کوتاه برای وقتی درست است که کالا کمیاب است یا قیمتش
به یک بازار متغیر گره خورده، چون قیمتی که اعلام کردهاید بعد از مدتی دیگر قیمتی نیست که اعلام میکردید —
کوینلند هیچ تبدیلی انجام نمیدهد، پس قیمت بیتکوین یکساعتپیش، دقیقاً قیمت بیتکوین یکساعتپیش است. TTL
بلند برای فاکتوری درست است که انتظار دارید فردا پرداخت شود. هر دو منطقیاند؛ آنچه مشکل میسازد انتخاب
بیفکر است.
## ایدمپوتنسی [#ایدمپوتنسی]
`reference_id` شناسه سفارش شماست و در همان حال کلید ایدمپوتنسی شماست. این API هدر جداگانه
`Idempotency-Key` ندارد.
| آنچه میفرستید | آنچه میگیرید |
| ------------------------------------ | ------------------------------- |
| یک `reference_id` تازه | یک جلسه پرداخت تازه (۲۰۱) |
| همان `reference_id` با محتوای یکسان | **همان جلسه اصلی**، بدون تغییر |
| همان `reference_id` با محتوای متفاوت | `PAY_DUPLICATE_REFERENCE` (۴۰۹) |
این کل قاعده است، و همین است که تلاش مجدد را ایمن میکند. وقتی `POST /sessions` با وقفه زمانی شکست
میخورد، هیچ چیزی به شما نمیگوید که جلسه ساخته شده یا نه، پس پاسخ درست ارسال دوباره همان درخواست است —
نه ساختن یک شناسه تازه، که همان کاری است که یک سفارش را به دو جلسه پرداخت و در نهایت به دو پرداخت تبدیل
میکند.
خطای ۴۰۹ روی محتوای تغییریافته یک ویژگی است: حالتی را میگیرد که شناسه یک سفارش را برای سفارشی دیگر
دوباره استفاده کردهاید. اگر واقعاً به قیمتهای متفاوت برای همان سبد نیاز دارید، آن در سیستم خودتان یک
سفارش تازه است و `reference_id` تازه میگیرد.
`reference_id` در تمام عمر حساب شما یگانه است، نه فقط میان جلسههای باز. همچنین یک کلید جستوجو است:
`GET /api/pay/v1/sessions/{id}` هم شناسه یکتای جلسه را میپذیرد و هم `reference_id` خودتان را، پس
میتوانید یک جلسه را بخوانید بدون آنکه چیزی از آنچه ما تولید کردهایم ذخیره کرده باشید.
## قیمتگذاری در چند ارز [#قیمتگذاری-در-چند-ارز]
`amounts` فهرستی از جفتهای `{currency, amount}` است، حداکثر ده تا، و مشتری دقیقاً یکی را انتخاب میکند.
```json
"amounts": [
{ "currency": "usdt", "amount": "24.90" },
{ "currency": "btc", "amount": "0.00027" },
{ "currency": "eth", "amount": "0.0069" }
]
```
**کوینلند هیچ تبدیلی انجام نمیدهد.** هر گزینه یک قیمت مستقل برای همان سفارش است، و هر کدام را که مشتری
انتخاب کند، همان مبلغ دقیقاً از او کسر میشود. این ریل هیچ نرخ لحظهای اعلام نمیکند، هیچ اسپردی اعمال
نمیکند، و یک ارز را به ارز دیگر تبدیل نمیکند.
پیامدش این است که همخوانی قیمتها بین ارزها کار شماست. اگر یک قیمت تتری و یک قیمت بیتکوینی اعلام کنید،
شما بودهاید که تصمیم گرفتهاید این دو ارزش یکسانی دارند، و شما هستید که ریسک حرکت بازار پیش از پرداخت
جلسه را میپذیرید. دو راه برای مدیریت آن:
* **در لحظه ساخت جلسه از یک نرخ زنده قیمت بگیرید** و `ttl_minutes` کوتاهی بگذارید، تا بازهای که بازار
میتواند به زیان شما حرکت کند کوچک باشد.
* **فقط یک ارز اعلام کنید.** یک `amounts` تکگزینهای کاملاً عادی است، و پرسش ریسک را از بین میبرد.
کوینلند پی فقط از ارزهای دیجیتال پشتیبانی میکند؛ پرداخت تومانی در این سرویس ارائه نمیشود. تومان
(IRT) بهعنوان ارز پذیرفتهشده یک کسبوکار قابل انتخاب نیست و قرار دادن آن در `amounts` با
`PAY_CURRENCY_NOT_ACCEPTED` رد میشود.
قواعدی که به آنها برمیخورید:
* هر `currency` باید در `accepted_currencies` شما از `GET /api/pay/v1/me` باشد، وگرنه کل درخواست با
`PAY_CURRENCY_NOT_ACCEPTED` (۴۲۲) رد میشود. آن فهرست را در زمان راهاندازی بخوانید و ارزها را در کد ثابت
نکنید، چون در کنسول و بدون انتشار نسخه جدید از سمت شما تغییر میکند.
* `amount` یک **رشته اعشاری** است: `"24.90"`، نه `24.9`. عددهای اعشاری شناور نمیتوانند هر مبلغ دهدهی را
نمایش دهند، و خطای گردکردن در اینجا یعنی خطای گردکردن در آنچه دریافت میکنید.
* مبالغ باید مثبت و در محدوده دقت آن ارز باشند. `PAY_AMOUNT_INVALID` (۴۲۲) هم مبلغ غیرمثبت را پوشش
میدهد، هم تعداد ارقام اعشار بیش از حد آن ارز، و هم هر چیزی بیرون از محدودههای پلتفرم.
* هر ارز یک بار میآید. دو گزینه برای یک ارز یعنی یک باگ در کد قیمتگذاری شما، نه انتخابی میان دو قیمت.
## قیمتگذاری به دلار [#قیمتگذاری-به-دلار]
بهجای `amounts` میتوانید یک `price_usd` بفرستید و کوینلند آن را در **همه ارزهایی که میپذیرید** با
نرخ زنده، در همان لحظه ساخت جلسه، قیمت میگذارد.
```json
{
"reference_id": "order-10492",
"title": "Order 10492",
"price_usd": "24.90"
}
```
یا `amounts` بفرستید یا `price_usd` — نه هر دو، نه هیچکدام. هر دو اشتباه با `PAY_AMOUNT_INVALID`
(۴۲۲) رد میشوند.
پاسخ جلسه با `pricing_mode: "usd"`، همان `price_usd` که فرستادهاید، و آرایه `amounts` از قیمتهای
گرفتهشده برمیگردد که هرکدام `usd_value` مبنای خود را همراه دارند. از آن به بعد دقیقاً مثل یک جلسه
با `amounts` رفتار میکند: مشتری یک ارز را انتخاب میکند و همان عدد را میپردازد.
تایمر جداگانهای برای قفل نرخ وجود ندارد. قیمتها یک بار و در لحظه ساخت گرفته میشوند، و مهلت خودِ
جلسه (`expires_at`) همان بازهای است که این قیمتها در آن معتبرند. مشتری همان مبلغی را میپردازد که
به او نشان داده شده. اگر بیش از حد طول بکشد، جلسه منقضی میشود و شما یکی تازه با نرخ همان لحظه
میسازید.
**ارزی که نرخ زنده نداشته باشد بیصدا کنار گذاشته میشود.** بقیه ارزهای پذیرفتهشده شما کار میکنند و
مشتری فقط گزینههای کمتری میبیند. تنها اگر *هیچ* ارزی قابل قیمتگذاری نباشد درخواست رد میشود، با
`PAY_RATE_UNAVAILABLE` (۵۰۳) — درخواستی که شکل درستی دارد و در سمت ما شکست خورده، پس دوباره تلاش
کنید نه اینکه تغییرش دهید.
### ایدمپوتنسی روی عدد دلاری است، نه روی قیمتها [#ایدمپوتنسی-روی-عدد-دلاری-است-نه-روی-قیمتها]
ارسال دوباره همان `reference_id` با همان `price_usd` **جلسه اصلی را با همان قیمتها برمیگرداند**،
حتی اگر نرخ زنده از آن زمان جابهجا شده باشد و قیمتهای تازه فرق کنند. اثر انگشت ایدمپوتنسی روی عدد
دلاریای که فرستادهاید گرفته میشود، نه روی مبالغ ارزی مشتقشده از آن. همین است که تلاش مجدد را ایمن
میکند: همان پیشنهادی را میگیرید که مشتری همین حالا جلوی چشمش است، نه یک قیمتگذاری تازه.
`price_usd` *متفاوت* با همان `reference_id` همچنان یک تعارض است و همچنان `PAY_DUPLICATE_REFERENCE`
(۴۰۹) میگیرد.
## متادیتا [#متادیتا]
`metadata` یک شیء JSON آزاد است که در هر خواندن جلسه پرداخت و روی پرداخت به شما بازگردانده میشود. جای
درست شناسه سبد، کانال فروش و کمپین شماست — هر چیزی که وگرنه باید جداگانه جستوجو کنید.
فقط شما آن را میبینید، اما ذخیره میشود، پس اطلاعات محرمانه و داده شخصی را در آن نگذارید. محتوای موردنظر
همان شناسههای خودتان است.
## خواندن یک جلسه پرداخت [#خواندن-یک-جلسه-پرداخت]
```ts
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();
}
```
```js
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.
async function getSession(id) {
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.
async function cancelSession(id) {
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();
}
```
```python
import os
from urllib.parse import quote
import requests
API = "https://my.coinlandexchange.com"
AUTH = {"Authorization": f"Bearer {os.environ['COINLAND_PAY_KEY']}"}
def get_session(session_id: str):
"""`session_id` may be the UUID or your own reference_id."""
res = requests.get(
f"{API}/api/pay/v1/sessions/{quote(session_id)}", headers=AUTH, timeout=15
)
res.raise_for_status()
return res.json()
def cancel_session(session_id: str):
"""Idempotent: cancelling a cancelled session returns it unchanged."""
res = requests.post(
f"{API}/api/pay/v1/sessions/{quote(session_id)}/cancel",
headers=AUTH,
timeout=15,
)
res.raise_for_status()
return res.json()
```
```rust
// Cargo.toml: percent-encoding = "2"
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
const API: &str = "https://my.coinlandexchange.com";
/// `id` may be the session UUID or your own reference_id.
pub async fn get_session(
http: &reqwest::Client,
api_key: &str,
id: &str,
) -> reqwest::Result {
let id = utf8_percent_encode(id, NON_ALPHANUMERIC);
http.get(format!("{API}/api/pay/v1/sessions/{id}"))
.bearer_auth(api_key)
.send()
.await?
.error_for_status()?
.json()
.await
}
/// Idempotent: cancelling a cancelled session returns it unchanged.
pub async fn cancel_session(
http: &reqwest::Client,
api_key: &str,
id: &str,
) -> reqwest::Result {
let id = utf8_percent_encode(id, NON_ALPHANUMERIC);
http.post(format!("{API}/api/pay/v1/sessions/{id}/cancel"))
.bearer_auth(api_key)
.send()
.await?
.error_for_status()?
.json()
.await
}
```
```php
true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => $auth,
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status < 200 || $status >= 300) {
throw new RuntimeException("coinland pay {$status}");
}
return json_decode($raw, true);
}
/** Idempotent: cancelling a cancelled session returns it unchanged. */
function cancel_session(string $id): array
{
global $api, $auth;
$ch = curl_init($api . '/api/pay/v1/sessions/' . rawurlencode($id) . '/cancel');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => $auth,
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status < 200 || $status >= 300) {
throw new RuntimeException("coinland pay {$status}");
}
return json_decode($raw, true);
}
```
این وضعیت معتبر است. وقتی `status` برابر `completed` باشد، [پرداخت](/concepts/payments) کامل در پاسخ
جایگذاری شده است، پس یک فراخوانی هم به «آیا پرداخت کرد» جواب میدهد و هم به «دقیقاً چه چیزی پرداخت کرد».
یک شناسه ناشناس — یا شناسهای که به کسبوکار دیگری تعلق دارد — با `PAY_SESSION_NOT_FOUND` (۴۰۴) پاسخ
میگیرد. این دو حالت عامدانه از هم قابل تشخیص نیستند: پاسخ متفاوت برای «وجود دارد اما مال شما نیست» به هر
کسی که یک کلید دارد اجازه میداد سفارشهای کسبوکارهای دیگر را شمارش کند.
# درباره API (/fa/reference)
صفحههای این بخش مستقیماً از سند OpenAPI کوینلند پی تولید میشوند، پس دقیقاً همان چیزی را توصیف میکنند که
هر اندپوینت میپذیرد و برمیگرداند. خودِ سند در [/openapi.json](/openapi.json) منتشر شده است، اگر میخواهید
از رویش کلاینت، سرور ساختگی یا تایپ تولید کنید.
صفحههای مرجع از `docs/openapi/pay.v1.yaml` تولید میشوند و متن قراردادشان انگلیسی است، در هر دو زبان
سایت. نام فیلدها، کدهای خطا و مسیرها بههرحال انگلیسیاند و ترجمه نمیشوند؛ راهنماهای مفهومی — که ترجمه
شدهاند — همان چیزی هستند که اینها را توضیح میدهند.
آنچه اینجا مستند شده همان چیزی است که یکپارچهسازی شما فراخوانی میکند. کارهای راهاندازی اینجا نیستند:
کلیدهای API، ارزهای پذیرفتهشده، برندینگ و نشانی وبهوک همه در
[کنسول کسبوکار](https://my.coinlandexchange.com/business) مدیریت میشوند، که تنها جایی است که چرخه کامل
هرکدام را دارد، پس اینجا اندپوینتی برایشان پیدا نمیکنید.
## نشانی پایه [#نشانی-پایه]
```text
https://my.coinlandexchange.com
```
هر اندپوینت کوینلند پی زیر `/api/pay/v1` قرار دارد. یک میزبان و یک محیط وجود دارد: جلسهای که میسازید
جلسهای است که یک مشتری واقعی میتواند پرداختش کند. برای تمرین ایمن [آماده انتشار](/go-live) را ببینید.
## احراز هویت [#احراز-هویت]
یک هدر روی هر درخواست:
```text
Authorization: Bearer clpay_live_<64 hex>
```
کلیدها در کنسول کسبوکار ساخته و باطل میشوند و فقط در لحظه ساخت نمایش داده میشوند. هر رد شدن در لایه
احراز هویت — هدر غایب، کلید بدشکل، کلید باطلشده، کلید ناشناس — با کد یگانه `UNAUTHORIZED` (۴۰۱) پاسخ
میگیرد، تا فراخواننده نتواند بفهمد کدام بخش اشتباه بوده است.
## قراردادها [#قراردادها]
درخواستها و پاسخها JSON هستند؛ روی هر چیزی که بدنه دارد `Content-Type: application/json` بفرستید.
**مبالغ رشته اعشاری هستند.** `"24.90"`، هرگز `24.9`. این برای `amount`، `fee_amount` و `net_amount` و در
درخواست و پاسخ یکسان صادق است. تا رسیدن به یک نوع اعشاری دقیق در کد خودتان، آنها را رشته نگه دارید.
**زمانها با قالب RFC 3339 و در UTC هستند.** شامل `expires_at`، `created_at`، `paid_at` و فیلترهای
`from` و `to`.
**شناسهها دو شکل دارند و هر دو بهعنوان کلید جستوجو کار میکنند.** جلسههای پرداخت و پرداختها شناسه یکتا
دارند، اما `GET /sessions/{id}` شناسه `reference_id` خودتان را هم میپذیرد و `GET /payments/{id}` شماره
رسید (`CLP-...`) را. پس میتوانید هر کدام را بخوانید بدون آنکه چیزی از آنچه کوینلند تولید کرده ذخیره کرده
باشید.
**فهرستها با مکاننما صفحهبندی میشوند و از جدید به قدیماند.** `limit` و `cursor` بفرستید و `next_cursor`
را دنبال کنید تا `null` برگردد. صفحهها را نشمارید و اندازه صفحه را فرض نگیرید.
**ایدمپوتنسی روی `reference_id` است.** هدر `Idempotency-Key` وجود ندارد. ارسال دوباره `POST /sessions` با
همان `reference_id` و محتوای یکسان جلسه اصلی را برمیگرداند؛ همان شناسه با محتوای متفاوت با
`PAY_DUPLICATE_REFERENCE` رد میشود. [جلسههای پرداخت](/concepts/sessions) را ببینید.
**«پیدا نشد» و «مال شما نیست» یکسان پاسخ میدهند.** `PAY_SESSION_NOT_FOUND` (۴۰۴) هر دو را پوشش میدهد، چون
پاسخ متفاوت برای «وجود دارد اما به کسبوکار دیگری تعلق دارد» به هر کسی با یک کلید اجازه میداد سفارشهای
کسبوکارهای دیگر را شمارش کند.
## پایداری [#پایداری]
API در مسیر و با `/v1` نسخهبندی شده است. درون یک نسخه، تغییرات افزودنیاند: اندپوینتهای تازه، فیلدهای
اختیاری تازه در درخواست و ویژگیهای تازه در پاسخ میتوانند بدون اطلاع قبلی ظاهر شوند، و فیلدهای موجود
معنایشان تغییر نمیکند و حذف نمیشوند. هر تغییر ناسازگار بهصورت یک نسخه تازه منتشر میشود و `/v1` به کار
خود ادامه میدهد.
چون ویژگیهای تازه در پاسخ هر زمانی میتوانند ظاهر شوند، یک deserializer که روی کلید ناشناخته خطا میدهد با
یک تغییر معمول و سازگار با نسخه قبل میشکند. آن را طوری تنظیم کنید که آنچه را نمیشناسد نادیده بگیرد.
همین درباره مقادیر شمارشی هم صادق است. کدهای خطای تازه و انواع رویداد تازه افزودنیاند، پس همیشه یک شاخه
پیشفرض داشته باشید.
## قابلیت اتکا [#قابلیت-اتکا]
وقفه زمانی را با همان `reference_id` دوباره تلاش کنید؛ ایدمپوتنسی برای همین است. روی `429` با تأخیر
تصادفی عقب بکشید. هر خطا از یک قالب واحد استفاده میکند — [خطاها](/errors) را ببینید.
## از کجا شروع کنیم [#از-کجا-شروع-کنیم]
[Sessions](/reference/sessions) مجموعه اندپوینتی است که هر یکپارچهسازی به آن نیاز دارد. `GET /me` اولین
فراخوانی است که ارزش انجام دادن دارد، چون میگوید مجاز به قیمتگذاری در چه ارزهایی هستید.
# حساب (/fa/reference/account)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# پرداختها (/fa/reference/payments)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# پرداخت به مشتری (/fa/reference/payouts)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# جلسههای پرداخت (/fa/reference/sessions)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Errors (/en/errors)
Every non-2xx response from Coinland Pay uses the same shape:
```json
{
"statusCode": 422,
"errors": {
"error": ["PAY_CURRENCY_NOT_ACCEPTED"]
}
}
```
`errors.error` is an array of **stable machine codes** in `UPPER_SNAKE`. There is no human-readable
message, in any language, anywhere in the response -- and that is deliberate: your customers read your
copy, not ours. Map the code to a sentence you wrote, in the language your customer speaks.
Codes are append-only. A code that ships never changes meaning and is never renamed, because your
translations and your alerting are keyed on it. New codes can appear, so branch on the ones you handle
and fall through to a generic message for the rest.
## Reading the envelope [#reading-the-envelope]
```js
const res = await fetch(url, init);
if (!res.ok) {
const body = await res.json().catch(() => null);
const code = body?.errors?.error?.[0] ?? "UNKNOWN";
throw new CoinlandPayError(code, res.status);
}
```
Two details worth building around:
* **The array can hold more than one code.** Validation failures may report several at once. Take the
first for your branch, log all of them.
* **Do not branch on the status alone.** Several codes share a status; the code is the contract and the
status is transport detail.
## Coinland Pay codes [#coinland-pay-codes]
### Sessions and payments [#sessions-and-payments]
| Code | HTTP | Meaning | What to do |
| --------------------------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `PAY_SESSION_NOT_FOUND` | 404 | No session or payment with that id under your account | Check the id. A session belonging to another business answers the same way, on purpose |
| `PAY_SESSION_EXPIRED` | 422 | The session's TTL passed before the customer confirmed | Create a new session; nothing was reserved |
| `PAY_SESSION_STATE` | 409 | The action is illegal in the session's current status, for example cancelling a completed one | Read the session and branch on `status` |
| `PAY_DUPLICATE_REFERENCE` | 409 | The same `reference_id` was sent with a **different** payload | Do not mint a new id. Either resend the original payload, or treat this as a new order |
| `PAY_CURRENCY_NOT_ACCEPTED` | 422 | A coin in `amounts` is not in your accepted set, is disabled platform-wide, or is Toman -- this rail is crypto only | Read `accepted_currencies` from `GET /me` at startup instead of hard-coding coins |
| `PAY_AMOUNT_INVALID` | 422 | Non-positive, too many decimal places for the coin, or outside platform bounds -- also raised when a session request sends both `amounts` and `price_usd`, or neither | Format amounts as decimal strings at the coin's own precision, and send exactly one of `amounts` or `price_usd` |
| `PAY_RATE_UNAVAILABLE` | 503 | `price_usd` was sent but no accepted coin could be priced right now | Retry rather than change the request; the request itself is fine |
| `PAY_SELF_PAYMENT` | 422 | The account trying to pay is the business that created the session | Nothing to fix in your code; a business cannot pay itself |
| `PAY_RECEIPT_INVALID` | 422 | Receipt verification failed: bad signature, altered payload, or unknown payment | Treat the receipt as untrusted. One code covers all three, so a forgery attempt learns nothing |
An identical replay of `POST /sessions` is **not** an error: the same `reference_id` with the same
payload returns the original session with a success status. Only a changed payload raises
`PAY_DUPLICATE_REFERENCE`. See [idempotency](/concepts/sessions#idempotency).
### Payouts and refunds [#payouts-and-refunds]
These come from the [payout direction](/payouts) and need a payout-class key.
| Code | HTTP | Meaning | What to do |
| ---------------------------- | ---- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `MERCHANT_PAYOUTS_DISABLED` | 403 | Payouts are not armed for your business, or the email-lookup path is not enabled for you | Contact Coinland. One code covers a disabled rail, a suspended business, payouts not switched on, and limits not yet set -- the remedy is the same |
| `PAY_WRONG_KEY_KIND` | 403 | A checkout key was used on a payout route, or a payout key on a checkout route | Use the key of the other class. The key itself is valid, which is why this is not `UNAUTHORIZED` |
| `PAY_RECIPIENT_INVALID` | 422 | The recipient cannot be paid, or the request sent both recipient paths, or neither | Check the `payer_id`, or redo the lookup. One code covers an unknown address, an ineligible account, an expired or foreign token and a mismatched `recipient_confirm`, so the endpoint cannot be used to probe who has an account |
| `PAY_PAYOUT_LIMIT` | 422 | Over the per-payout maximum or the trailing-24-hour ceiling | Split the payout across days, wait out the window, or ask Coinland to raise the limit |
| `PAY_REFUND_EXCEEDS_PAYMENT` | 422 | Cumulative refunds would exceed the payment's `charged_amount` | Sum your own refunds of that payment against its `charged_amount` and refund only what is left |
| `PAY_LOOKUP_THROTTLED` | 429 | Your recipient-lookup budget for the minute or the day is spent | Back off on lookups. Payouts by `payer_id` are unaffected |
`INSUFFICIENT_BALANCE` (422) and `PAY_RATE_UNAVAILABLE` (503) also reach this surface: the first when
your business wallet does not cover `amount + fee`, the second when the coin has no live USD rate and
the limits therefore cannot be enforced. A payout is refused rather than sent unmetered.
### Your merchant account [#your-merchant-account]
| Code | HTTP | Meaning | What to do |
| --------------------------------- | ---- | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `MERCHANT_NOT_FOUND` | 404 | The account behind this key is not a business | Ask Coinland support to promote the account |
| `MERCHANT_DISABLED` | 403 | This business cannot take payments right now | Contact Coinland. One code covers a suspended business and a globally disabled rail; the distinction is operator-only because the remedy is identical |
| `MERCHANT_KEY_LIMIT` | 422 | The active API-key ceiling is reached | Revoke a key in the business console before minting another |
| `MERCHANT_WEBHOOK_URL_INVALID` | 422 | Not https, unparsable, or pointed at a private address | Use a publicly reachable https URL |
| `MERCHANT_LOGO_INVALID` | 422 | The uploaded logo is outside the type or size allowlist | Check the limits shown in the console |
| `MERCHANT_CONVERT_TARGET_INVALID` | 422 | The auto-convert target is not a coin Coinland currently allows as a conversion destination | Pick one of the targets the console offers |
| `MERCHANT_EXISTS` | 409 | Promotion was attempted on an account that is already a business | Nothing to do; you are already promoted |
The last five come from the business console rather than the merchant API. They are listed here because
the console and the API share one envelope and one catalog, so a code you see in the browser means the
same thing it would mean over the API.
It means Coinland has switched your checkout off, either for your business specifically or across the
platform. Retrying will not clear it. Surface a maintenance state to your customers and get in touch.
## Generic codes [#generic-codes]
These are platform-wide and can be returned by any endpoint.
| Code | HTTP | Meaning |
| ----------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `UNAUTHORIZED` | 401 | Missing, malformed, revoked or unknown API key. One code for every auth-layer refusal, so a caller cannot probe which part was wrong |
| `FORBIDDEN` | 403 | Authenticated, but not permitted |
| `VALIDATION_FAILED` | 422 | The request body failed schema validation: a missing required field, a wrong type, a string over its maximum |
| `RATE_LIMITED` | 429 | Too many requests. Back off with jitter before retrying |
| `NOT_FOUND` | 404 | No such route |
| `CONFLICT` | 409 | A generic state conflict, where no more specific code applies |
| `INTERNAL_SERVER_ERROR` | 500 | Something failed on our side |
| `SERVICE_UNAVAILABLE` | 503 | Temporarily unable to serve. Retry with backoff |
| `MAINTENANCE_MODE` | 503 | Coinland is in maintenance. Reads and writes are both off |
## Which errors to retry [#which-errors-to-retry]
| Situation | Retry? |
| --------------------------------- | ---------------------------------------------------------------------------- |
| A timeout or a dropped connection | **Yes**, with the same `reference_id`. That is what idempotency is for |
| `429 RATE_LIMITED` | Yes, after a backoff with jitter |
| `500`, `502`, `503` | Yes, with exponential backoff |
| Any other 4xx | **No.** The request is wrong; retrying it unchanged produces the same answer |
It tells you nothing about whether the session was created. Resend the identical request with the same
`reference_id`: if it went through you get the original session back, and if it did not you get a new
one. Minting a fresh id instead is how one order ends up with two sessions.
## Mapping codes to your own copy [#mapping-codes-to-your-own-copy]
Keep the map in one place, keyed on the code, with a fallback:
```js
const MESSAGES = {
PAY_SESSION_EXPIRED: "This checkout expired. Start again to get a fresh one.",
PAY_CURRENCY_NOT_ACCEPTED: "We cannot take that coin right now.",
PAY_AMOUNT_INVALID: "Something is wrong with the order total.",
MERCHANT_DISABLED: "Coinland payments are unavailable at the moment.",
RATE_LIMITED: "Too many attempts. Try again in a minute.",
};
// A code you have never seen must still produce a sentence, because new codes
// ship without a version bump.
export const messageFor = (code) => MESSAGES[code] ?? "Payment could not be completed.";
```
Log the raw code next to your own request id, whatever you show the customer. When you contact Coinland
support, the code plus the session or payment id is enough to find the exact event.
# Going live (/en/go-live)
Coinland Pay moves real money on the first call. There is no sandbox key and no test mode: a session
you create is a session a customer can pay, so the way to rehearse is with your own account and a small
amount in a coin you hold.
That makes the checklist below worth actually walking rather than skimming.
## Key hygiene [#key-hygiene]
Your API key is a bearer credential. Anyone holding it can create sessions in your name, read every
payment you have ever taken, and see your customers' order references.
There are two key classes and they are separate credentials: `clpay_live_` for checkout,
`clpay_payout_` for [payouts and refunds](/payouts). Everything below applies to both, and the split
buys you one thing worth having -- the key that gets copied into the most places cannot move money out
of your wallet.
* **Server-side only.** Never in a browser bundle, a mobile app binary, a public repository, a CI log or
a support ticket. If a key has ever been in any of those, it is compromised regardless of what has
happened since.
* **Shown once.** Coinland stores a hash, never the value, so a lost key cannot be recovered. Only the
prefix is kept, to tell keys apart in the console.
* **Rotate by overlap.** Mint the new key, deploy it, confirm traffic is flowing on it, then revoke the
old one. Revoking first means an outage between deploys.
* **One key per environment.** Separate keys for staging and production mean you can revoke one without
touching the other, and the prefix in your logs tells you which system made a call.
* **Revoke on suspicion, not on proof.** Revocation is instant and minting a replacement takes seconds.
There is no scenario where waiting for certainty is the better trade.
Your **webhook secret** is a second credential with a different job: it verifies what we send you and
signs your [receipt tokens](/concepts/receipts). Same rules, and one extra -- rotating it invalidates
signatures on receipts issued under the old secret, so keep the previous value long enough to verify
tokens your customers are still holding.
## HTTPS everywhere [#https-everywhere]
Three URLs you provide, and all three must be https:
| URL | Where | Why |
| ------------ | ---------------- | ----------------------------------------------------------------------------------------------------------------- |
| Webhook URL | Business console | A plaintext webhook is refused with `MERCHANT_WEBHOOK_URL_INVALID`. It carries payment ids over the open internet |
| `return_url` | Per session | It carries a receipt token in the query string |
| `cancel_url` | Per session | Consistency, and it is a page your customer lands on from Coinland |
Your webhook URL also has to be reachable from the public internet. We cannot deliver to `localhost`, a
private address, or anything behind your VPN. For local development, use a tunnel and point the console
at the tunnel's public https URL.
## Money handling [#money-handling]
Every amount in this API is a decimal string, and it should stay a string until it reaches a decimal
type. `parseFloat("24.90")` is a number that cannot represent 24.90 exactly, and the error compounds
the moment you sum a day's payments.
* Use your language's decimal type: `BigDecimal`, `decimal.Decimal`, `Decimal` from a library, or an
integer count of the coin's smallest unit.
* Store `amount`, `fee_amount` and `net_amount` as strings or decimals in your database. A `float`
column is a slow-motion reconciliation bug.
* Reconcile order totals against `amount` and your books against `net_amount`. They differ by
Coinland's fee, and using one for both is what makes a ledger drift. See
[Payments](/concepts/payments#amount-fee-and-net).
* Compare amounts with a decimal comparison, never `==` on parsed numbers.
## Before your first real payment [#before-your-first-real-payment]
**Verify the key.** `GET /api/pay/v1/me` returns 200, and `accepted_currencies` holds every coin you
intend to price in. Your code reads that list rather than hard-coding it.
**Create and read a session.** `POST /sessions` returns 201, and `GET /sessions/{reference_id}` finds it
by your own id. Both amounts are decimal strings.
**Prove idempotency.** Send the identical `POST /sessions` twice. You get the same session id back, not
two sessions. Then send it a third time with a changed amount and confirm you get
`PAY_DUPLICATE_REFERENCE`.
**Pay one yourself.** Use a small amount in a coin you hold. Confirm the widget opens, the payment
settles, and your balance moves by `net_amount`.
**Confirm the webhook lands and verifies.** Your handler validates the signature, enforces the
five-minute window, compares in constant time, and rejects a request whose body you have deliberately
tampered with.
**Prove deduplication.** Replay the same delivery to your own endpoint. The order must be fulfilled once.
A unique constraint on `event_id` is the mechanism; a `SELECT` then an `INSERT` has a race two
concurrent retries will find.
**Test the redirect path.** Block popups in your browser and pay again. Your `return_url` page must
handle arriving before the webhook has landed, and show a pending state rather than an error.
**Test cancellation and expiry.** Cancel a session and confirm your order closes. Let one expire and
confirm the `session.expired` event releases the cart.
**Verify a receipt offline.** Take the token from your test payment, verify it with your webhook secret,
then flip one character in the payload and confirm your verifier rejects it.
**Read back the payment.** `GET /payments/{receipt_no}` finds it by receipt number, and the amounts match
what you charged.
## Before your first payout [#before-first-payout]
Only if you send money out. [Payouts](/payouts) are a separate arming step, not part of your checkout
go-live.
**Confirm you are armed.** Coinland has enabled payouts for your business AND set both USD limits.
Until both are true, every payout answers `MERCHANT_PAYOUTS_DISABLED` -- unset limits refuse
everything rather than meaning "unlimited".
**Pay out a small amount** by `payer_id` — the handle from a test purchase made by a SECOND test
account (paying your own business account is refused as a self-payout). Confirm that account
receives exactly `amount` and your business wallet drops by `debited_amount`.
**Prove payout idempotency.** Send the identical `POST /payouts` twice. The second answers 201 with the
same payout id and moves no money. This is the single most important thing to verify on this rail,
because the failure mode is paying someone twice.
**Refund a payment, partially.** Then refund the remainder, then attempt one more and confirm
`PAY_REFUND_EXCEEDS_PAYMENT`. Check that `fee_amount` was `"0"` on both.
**Trip your own limit.** Attempt a payout above your per-payout maximum and confirm your code surfaces
`PAY_PAYOUT_LIMIT` to a human rather than retrying it as if it were transient.
## Operational habits [#operational-habits]
**Do not depend on the webhook alone.** It is the primary path, not the only one. Two cheap habits make
an integration survive a delivery that never arrives:
* Your `return_url` page polls `GET /sessions/{id}` while the customer is watching, so the common case
resolves in a second regardless of webhook timing.
* A daily sweep lists recent payments and reconciles them against open orders, which also catches
anything paid while your server was down.
**Answer webhooks fast and work afterwards.** A handler that ships the goods before responding will
eventually exceed the delivery timeout, and the retry will find your idempotency check as the only thing
between one order and two.
**Log the code and the ids.** On any failure, record the machine code from
[the error envelope](/errors), the session id and your `reference_id`. That triple is enough for Coinland
support to find the exact event without a back-and-forth.
**Alert on silence.** A day with zero `payment.completed` events, on a store that normally takes
payments, is the signal that something broke on your side or ours. Nobody notices a webhook endpoint
that quietly stopped being called until the accounting comes up short.
## What this rail does not do [#what-this-rail-does-not-do]
Worth knowing before you design around it:
* **No automatic reversal.** A settled payment is never undone on its own or by a dispute process.
Giving money back is a [refund](/payouts#refunds) you choose to send, and Coinland keeps the fee it
charged on the original payment.
* **No partial capture and no authorisation hold.** A payment settles in full or does not happen.
* **No conversion.** You price in each coin you accept and the customer pays exactly that. Cross-coin
consistency, and the market risk in it, is yours. See
[pricing in multiple coins](/concepts/sessions#pricing-in-multiple-coins).
* **No recurring billing.** There are no subscriptions on this rail. A repeat charge is a new session
the customer approves.
* **Only Coinland customers can pay.** The payer needs a Coinland account with a balance. This is a rail
for reaching Coinland's customers, not a general card processor.
# Coinland Pay (/en)
Coinland Pay lets your site take payment from anyone who holds a balance on Coinland. You create a
checkout session on your server, send the customer to a hosted widget, and the amount moves from
their Coinland balance into your Coinland business account.
There is no blockchain in that path. Both accounts live on Coinland, so a payment is an internal
ledger transfer: it settles in one hop, it confirms in the same request the customer approves it in,
and it costs no network fee at any amount. A 4 USDT payment is as economical as a 4,000 USDT one.
## What the integration looks like [#what-the-integration-looks-like]
**You create a session** with `POST /api/pay/v1/sessions`, server-side, using your secret key. You
give it your own order id, a title, and the price in each coin you are willing to accept.
**The customer pays in the hosted widget.** You either open it in a popup with the embed script or
redirect to the session's `checkout_url`. They sign in to Coinland, pick one of your coins, confirm,
and the transfer settles.
**You are notified and you fulfil.** Coinland POSTs a signed `payment.completed` webhook; you read
the authoritative record with `GET /api/pay/v1/payments/{id}` and release the goods.
Start with the [Quickstart](/quickstart) for the copy-paste version of all three.
## Why it is shaped this way [#why-it-is-shaped-this-way]
**The widget is hosted, and that is the point.** The customer authenticates and confirms on
Coinland's own origin, never on yours. No Coinland password, OTP or TOTP code is ever typed into a
page you serve, which means a compromise of your frontend cannot become a compromise of your
customers' Coinland accounts. Your site never handles a credential and never needs to be trusted
with one.
**You price in coins, and Coinland converts nothing.** A session carries a list of
`{currency, amount}` options and the customer picks exactly one of them. If you list `10.5` USDT and
`0.00012` BTC, then whichever they choose, that is the number that moves. This rail quotes no spot
rate and applies no conversion, so the amount you asked for is the amount you can reconcile against.
**Payments cannot be faked.** Every completed payment carries a
[receipt token](/concepts/receipts) signed with your webhook secret. A customer cannot fabricate one
without that secret, so a receipt is portable proof of payment that any of your systems can check
offline. It is still not what you fulfil on -- that stays the webhook and the API.
## Before you write any code [#before-you-write-any-code]
Two things have to exist first, and neither is self-serve from the API:
1. **Your account is promoted to a business.** Coinland staff do this. It turns your ordinary
customer account into a business, which gets its own **business wallet** for your takings, kept
apart from your personal balance.
2. **You have an API key.** You mint it yourself in the business console once you are promoted, and
it is shown once.
[Quickstart](/quickstart) walks through both.
## Where to go next [#where-to-go-next]
| If you want to | Read |
| ------------------------------------------------ | ------------------------------ |
| The shortest path to a working payment | [Quickstart](/quickstart) |
| To embed the widget in your checkout page | [Widget](/widget) |
| To understand the session lifecycle and retries | [Sessions](/concepts/sessions) |
| To reconcile fees and receipt numbers | [Payments](/concepts/payments) |
| To verify a receipt without calling us | [Receipts](/concepts/receipts) |
| To send money to a customer, or refund a payment | [Payouts](/payouts) |
| To handle notifications correctly | [Webhooks](/webhooks) |
| Every endpoint, field by field | [API reference](/reference) |
The machine-readable versions of this site are at [/llms.txt](/llms.txt) and
[/llms-full.txt](/llms-full.txt), and every page has a Markdown twin at its own path plus `.md`. The
OpenAPI document is published at [/openapi.json](/openapi.json).
# Payouts and refunds (/en/payouts)
Coinland Pay moves money in two directions. Taking a payment is a [checkout session](/concepts/sessions)
the customer confirms. Sending money is a **payout**: you name a recipient and an amount, and coin
leaves your business wallet for their Coinland balance.
A **refund** is the same operation aimed at a payment you already took, and it answers with the same
object. The difference is that a refund takes its recipient and its currency from the payment record
rather than from you, and that it is free.
There is no pending state, no approval step and nothing to poll. By the time you read the response,
your wallet has been debited and the recipient has been credited. Treat a timeout on this endpoint the
way you would treat a timeout on a wire transfer: resend the identical request with the same
`reference_id` and let idempotency tell you what happened.
## The payout key [#the-payout-key]
Payouts need their own key class. Your checkout key cannot call them, and a payout key cannot create
sessions.
```text
clpay_live_<64 hex> the CHECKOUT class -- sessions, payments, receipts
clpay_payout_<64 hex> the PAYOUT class -- payouts, refunds
```
Mint one in your business console, choosing the payout class. As with a checkout key, it is shown once
and stored only as a hash, and you may hold up to 5 enabled keys of each class.
A payout key can also read `GET /payments`, `GET /payments/{id}` and `GET /me`, because reconciling
what you sent against what you took needs both sides. It can do nothing else on the checkout surface.
Using the wrong class is `PAY_WRONG_KEY_KIND` (403) rather than an authentication failure -- the key is
valid, it is simply the other one you want.
A checkout key is the one that ends up in more places: in the service that creates sessions, in a
staging environment, in a deployment pipeline. Splitting the classes means the widely-copied key
cannot move money out of your wallet, and revoking it does not stop you taking payments.
## Before your first payout [#before-your-first-payout]
Payouts are switched off until Coinland turns them on for your business, and turning them on means two
things, not one:
1. **Payouts are enabled for your account.**
2. **Both of your USD limits are set** -- a maximum per payout, and a ceiling on the trailing 24 hours.
Limits are not optional and there is no unlimited setting. An account with payouts enabled but no
limits configured is not armed, and it refuses every payout: money leaving is never open-ended by
omission.
Until all of that is in place, every write answers `MERCHANT_PAYOUTS_DISABLED` (403). The same code
covers a suspended business and a rail Coinland has paused globally, because the remedy is the same one:
talk to us. It is not a code to retry through.
## Paying a customer who has paid you [#paying-a-customer-who-has-paid-you]
Every payment object carries a `payer_id`: an opaque handle for the customer who paid, scoped to your
business.
```json
{
"id": "b92e4d17-6c38-4a05-9f2b-1e7d3c8a5049",
"reference_id": "order-10492",
"currency": "usdt",
"amount": "24.90",
"payer_id": "7c1e5b90-3f42-4a86-9d05-2b8e4c1f6a37",
"paid_at": "2026-08-11T08:12:44.000Z"
}
```
That handle is stable: the same customer paying you again next month carries the same one. It is not a
Coinland user id, it means nothing to any other business, and it reveals nothing about the person. It is
also the entire recipient argument for a payout -- store it against your own customer record and you
never need an email address to pay them.
Handles exist for every payment ever taken on this rail, including ones taken before payouts existed.
```bash
curl -X POST https://my.coinlandexchange.com/api/pay/v1/payouts \
-H "Authorization: Bearer $COINLAND_PAY_PAYOUT_KEY" \
-H "Content-Type: application/json" \
-d '{
"reference_id": "payout-2291",
"currency": "usdt",
"amount": "25.00",
"payer_id": "7c1e5b90-3f42-4a86-9d05-2b8e4c1f6a37",
"comment": "Cashback for order 10492"
}'
```
```json title="201 Created"
{
"id": "9e3c7a41-0b52-4f18-8d6a-3c7e1f9b40d5",
"reference_id": "payout-2291",
"kind": "payout",
"status": "completed",
"currency": "usdt",
"amount": "25.00",
"debited_amount": "25.125",
"fee_amount": "0.125",
"fee_percent": "0.5",
"usd_value": "25.00",
"payer_id": "7c1e5b90-3f42-4a86-9d05-2b8e4c1f6a37",
"payment_id": null,
"comment": "Cashback for order 10492",
"created_at": "2026-08-11T09:31:04.000Z",
"settled_at": "2026-08-11T09:31:04.000Z"
}
```
The currency does not have to be one you accept at checkout. Any enabled coin your business wallet
holds can be paid out, which matters if you convert your takings into a stablecoin. Toman is refused:
this rail is crypto only, in both directions.
## Paying someone by email [#paying-someone-by-email]
For a recipient who has never paid you, there is no handle to use, so there is a second path: look the
address up, show a human the name that comes back, and spend the resulting token.
This path is off by default. Coinland enables it per business, and where it is not enabled the lookup
answers `MERCHANT_PAYOUTS_DISABLED` -- the same answer an unpayable address gets, so the endpoint cannot
be used to test whether the feature is on either.
**Look up the address.**
```bash
curl -X POST https://my.coinlandexchange.com/api/pay/v1/payouts/recipients/lookup \
-H "Authorization: Bearer $COINLAND_PAY_PAYOUT_KEY" \
-H "Content-Type: application/json" \
-d '{"email": "customer@example.com"}'
```
```json title="200 OK"
{
"recipient_token": "v1.eyJtZXJjaGFudElkIjo0Miwi….9f3c1d60ab72",
"masked_name": "A**** B****",
"expires_at": "2026-08-11T09:41:22.000Z"
}
```
The token is valid for ten minutes, works only for the business that requested it, and carries the
resolved recipient inside its own signature. The email address never travels on the payout request.
**Show `masked_name` to a person and have them confirm it.**
The mask is enough to recognise someone you already meant to pay and not enough to identify a stranger.
That is the whole point of it: it catches a mistyped address before the money moves, without turning
our customer directory into something you can read.
**Create the payout, echoing the masked name back verbatim.**
```bash
curl -X POST https://my.coinlandexchange.com/api/pay/v1/payouts \
-H "Authorization: Bearer $COINLAND_PAY_PAYOUT_KEY" \
-H "Content-Type: application/json" \
-d '{
"reference_id": "payout-2292",
"currency": "usdt",
"amount": "40.50",
"recipient_token": "v1.eyJtZXJjaGFudElkIjo0Miwi….9f3c1d60ab72",
"recipient_confirm": "A**** B****"
}'
```
`recipient_confirm` must match the `masked_name` character for character. A mismatch is refused, which
is what makes step 2 a real check rather than a screen someone clicks through.
The response carries a `payer_id` for the recipient, so the next payout to the same person can skip all
of this and use the handle.
An address with no Coinland account, a disabled account, an account that has not finished identity
verification, an expired token, a token minted for another business, a confirm that does not match --
all of them answer `PAY_RECIPIENT_INVALID` (422). The endpoint deliberately will not tell you which,
because a lookup that distinguished them would be a way to find out who banks with us.
Lookups are also metered per business. Exceeding the budget is `PAY_LOOKUP_THROTTLED` (429), which
affects only lookups -- payouts by handle keep working.
## Refunds [#refunds]
A refund returns coin to the customer who paid you. Address it by payment id or by receipt number, and
send only an amount:
```bash
curl -X POST https://my.coinlandexchange.com/api/pay/v1/payments/CLP-10492-8F3A/refund \
-H "Authorization: Bearer $COINLAND_PAY_PAYOUT_KEY" \
-H "Content-Type: application/json" \
-d '{
"reference_id": "refund-10492-1",
"amount": "10.00",
"comment": "One item returned"
}'
```
There is no recipient field and no currency field, on purpose: both come from the payment, so a refund
can only travel back the way the money came.
* **Partial refunds are allowed**, and you can issue several against one payment.
* **The cap is cumulative**, at the payment's `charged_amount`. A refund that would take the running
total past it is `PAY_REFUND_EXCEEDS_PAYMENT` (422), and the error reports how much has already been
refunded.
* **Refunds are free.** `fee_amount` is `"0"` and `debited_amount` equals `amount`, so a refund costs
you the coin and nothing more.
Coinland keeps the fee it charged on the original payment and takes no new fee on the way back. One
flow is charged once: you are not billed twice for a sale that unwound, and the original fee is not
returned either.
The result reads as a payout with `kind: "refund"` and the refunded payment's id in `payment_id`.
## Fees [#fees]
Payouts are charged at the same tier rate as your payments, and **you always bear it**. The recipient
receives exactly the `amount` you named -- there is no version of this where they are shown one figure
and credited another.
```text
recipient receives amount
your wallet pays debited_amount == amount + fee_amount
```
`debited_amount - amount == fee_amount` holds exactly, on every payout. Reconcile your wallet against
`debited_amount` and what you promised the recipient against `amount`; using one figure for both is what
makes books drift.
Payout volume counts toward the 30-day USD volume that decides your fee tier, so money you send helps
you reach a better rate. The payment *count* thresholds still count payments only.
## Limits [#limits]
Two limits apply, both in USD, and both are set by Coinland rather than by you.
| Limit | Applies to | Over it |
| ------------------------ | ---------------------------- | --------------------------------------------------------- |
| Per-payout maximum | One payout | `PAY_PAYOUT_LIMIT` (422), `details.limit` is `per-payout` |
| Trailing 24-hour ceiling | The sum of the last 24 hours | `PAY_PAYOUT_LIMIT` (422), `details.limit` is `daily` |
A refund is exempt from the per-payout maximum -- a limit lower than a payment must not make that
payment unrefundable -- but it still counts toward the daily ceiling.
Both are measured in USD, which means a payout can only be sent if the coin can be valued right now. If
no live rate is available the payout is refused with `PAY_RATE_UNAVAILABLE` (503) rather than sent
unmetered. The request is fine; retry it.
If your business wallet does not cover `amount + fee`, the answer is `INSUFFICIENT_BALANCE` (422). Move
funds from your spot balance into the business wallet in your console and retry.
## Idempotency [#idempotency]
`reference_id` is your idempotency key, and it works exactly as it does on sessions:
* The **same** `reference_id` with an **identical** payload returns the original payout, with `201`. No
second transfer happens.
* The **same** `reference_id` with a **different** payload is `PAY_DUPLICATE_REFERENCE` (409).
```js
// A timeout tells you nothing about whether the payout settled. Resend the
// identical request -- never a fresh reference_id, which is how one payout
// becomes two.
async function payOut(body) {
for (let attempt = 0; attempt < 3; attempt++) {
try {
return await post("/api/pay/v1/payouts", body);
} catch (err) {
if (!isTimeout(err)) throw err;
await sleep(2 ** attempt * 1000);
}
}
// Still unsure? Read it back by your own id.
return get(`/api/pay/v1/payouts/${body.reference_id}`);
}
```
`GET /payouts/{id}` accepts either the payout id or your `reference_id`, which makes that last line a
reliable way to settle the question after a network failure.
## Knowing it happened [#knowing-it-happened]
Since a payout settles inside the request, the response is already authoritative and you rarely need
anything else. A `payout.completed` webhook is delivered as well, for the case where the payout was
created somewhere other than your own code -- from the business console, for instance:
```json title="payout.completed"
{
"event_id": "a17b3e50-9d24-4c81-b6f3-5e0a2c7d1948",
"type": "payout.completed",
"payout_id": "9e3c7a41-0b52-4f18-8d6a-3c7e1f9b40d5",
"reference_id": "payout-2291",
"kind": "payout",
"status": "completed"
}
```
Same signature scheme, same five-minute window, same deduplication rule, and the same warning: the
payload is a hint. It carries no amounts, and anything you act on should come from
`GET /payouts/{id}`. See [webhooks](/webhooks) for the verification code.
Note that `kind` distinguishes a payout from a refund on this event, so a handler that credits a
customer's loyalty balance on payouts should branch on it rather than assume.
## Errors [#errors]
| Code | HTTP | Meaning | What to do |
| ---------------------------- | ---- | --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| `MERCHANT_PAYOUTS_DISABLED` | 403 | Payouts are not armed for your business, or the email path is not enabled for you | Contact Coinland. Not retryable |
| `PAY_WRONG_KEY_KIND` | 403 | A checkout key on a payout route, or the reverse | Use the key of the other class |
| `PAY_RECIPIENT_INVALID` | 422 | The recipient cannot be paid, or you sent both recipient paths, or neither | Check the handle or redo the lookup. One code covers every reason on purpose |
| `PAY_PAYOUT_LIMIT` | 422 | Over the per-payout maximum or the 24-hour ceiling | Read `details.limit`. Split the payout, wait out the window, or ask Coinland to raise it |
| `PAY_REFUND_EXCEEDS_PAYMENT` | 422 | Cumulative refunds would exceed the payment's `charged_amount` | Refund the remainder instead; `details.already_refunded` says how much is gone |
| `PAY_LOOKUP_THROTTLED` | 429 | The lookup budget for the minute or the day is spent | Back off. Payouts by handle are unaffected |
| `INSUFFICIENT_BALANCE` | 422 | The business wallet does not cover `amount + fee` | Top the wallet up from your spot balance and retry |
| `PAY_RATE_UNAVAILABLE` | 503 | The coin has no live USD rate, so the limits cannot be enforced | Retry; the request itself is fine |
| `PAY_DUPLICATE_REFERENCE` | 409 | The same `reference_id` with a different payload | Resend the original payload, or use a new id for a genuinely new payout |
The full catalog, including the codes shared with checkout, is on the [errors](/errors) page.
## Checklist [#checklist]
* Payout key minted separately, stored separately from the checkout key.
* `payer_id` persisted against your own customer records at payment time.
* `reference_id` derived from something stable in your system, never a random value per attempt.
* Timeouts retried with the identical request, never a fresh id.
* `amount` and `debited_amount` both stored, as decimal strings.
* A human confirms `masked_name` before any email-path payout.
* `PAY_PAYOUT_LIMIT` handled as a business condition, not a bug -- someone should be told.
# Quickstart (/en/quickstart)
This is the whole integration end to end. Five steps, no SDK required.
## 1. Get promoted to a business [#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](https://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 with
`PAY_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) and your **webhook secret**, which also signs receipt tokens.
* **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](/concepts/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](/concepts/payments).
## 2. Mint an API key [#2-mint-an-api-key]
In the business console, create a key. It looks like this:
```text
clpay_live_4f9d2c8a1b7e6f3d0a5c9b2e8f1a6d4c7b0e3f9a2c5d8b1e4f7a0c3d6b9e2f5a
```
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. Every request carries it as a bearer token:
```text
Authorization: Bearer clpay_live_<64 hex>
```
Confirm the key works, and see what you are allowed to price in, with one call:
```bash title="Check your key"
curl https://my.coinlandexchange.com/api/pay/v1/me \
-H "Authorization: Bearer $COINLAND_PAY_KEY"
```
```json title="Response"
{
"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 [#3-create-a-checkout-session]
One call per order, from your server, at the moment the customer chooses to pay.
```ts
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
}
export async function createSession(order: {
id: string;
number: string;
amounts: Amount[];
}) {
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,
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();
}
```
```js
const API = "https://my.coinlandexchange.com";
const apiKey = process.env.COINLAND_PAY_KEY; // clpay_live_...
export async function createSession(order) {
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}`,
// decimal STRINGS, never floats
amounts: order.amounts,
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();
}
```
```python
import os
import requests
API = "https://my.coinlandexchange.com"
API_KEY = os.environ["COINLAND_PAY_KEY"] # clpay_live_...
def create_session(order):
res = requests.post(
f"{API}/api/pay/v1/sessions",
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
},
json={
"reference_id": order["id"], # your order id, and your idempotency key
"title": f"Order {order['number']}",
# Decimal STRINGS. Never float() an amount, and never let a JSON
# encoder turn a Decimal into one.
"amounts": order["amounts"],
"return_url": "https://example.com/checkout/done",
"cancel_url": "https://example.com/cart",
},
timeout=15,
)
if not res.ok:
code = "UNKNOWN"
try:
code = res.json()["errors"]["error"][0]
except (ValueError, KeyError, IndexError):
pass
raise RuntimeError(f"coinland pay {res.status_code}: {code}")
return res.json()
```
```rust
// Cargo.toml: reqwest = { version = "0.12", features = ["json"] }
// serde = { version = "1", features = ["derive"] }
// serde_json = "1", tokio = { version = "1", features = ["full"] }
use serde::{Deserialize, Serialize};
const API: &str = "https://my.coinlandexchange.com";
#[derive(Serialize)]
pub struct Amount<'a> {
pub currency: &'a str,
/// Decimal STRING, never an f64.
pub amount: &'a str,
}
#[derive(Serialize)]
struct CreateSession<'a> {
reference_id: &'a str,
title: &'a str,
amounts: &'a [Amount<'a>],
return_url: &'a str,
cancel_url: &'a str,
}
#[derive(Deserialize, Debug)]
pub struct Session {
pub id: String,
pub status: String,
pub checkout_url: String,
}
pub async fn create_session(
http: &reqwest::Client,
api_key: &str, // clpay_live_...
order_id: &str,
title: &str,
amounts: &[Amount<'_>],
) -> Result> {
let res = http
.post(format!("{API}/api/pay/v1/sessions"))
.bearer_auth(api_key)
.json(&CreateSession {
reference_id: order_id, // your order id, and your idempotency key
title,
amounts,
return_url: "https://example.com/checkout/done",
cancel_url: "https://example.com/cart",
})
.send()
.await?;
if !res.status().is_success() {
let status = res.status();
let body = res.text().await.unwrap_or_default();
return Err(format!("coinland pay {status}: {body}").into());
}
Ok(res.json().await?)
}
```
```php
true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('COINLAND_PAY_KEY'), // clpay_live_...
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'reference_id' => $order->id, // your order id, and your idempotency key
'title' => 'Order ' . $order->number,
// Decimal STRINGS, never floats.
'amounts' => [['currency' => 'usdt', 'amount' => $order->total_usdt]],
'return_url' => 'https://example.com/checkout/done',
'cancel_url' => 'https://example.com/cart',
]),
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status < 200 || $status >= 300) {
$code = json_decode($raw, true)['errors']['error'][0] ?? 'UNKNOWN';
throw new RuntimeException("coinland pay {$status}: {$code}");
}
$session = json_decode($raw, true);
```
```bash
curl -X POST https://my.coinlandexchange.com/api/pay/v1/sessions \
-H "Authorization: Bearer $COINLAND_PAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"reference_id": "order-10492",
"title": "Order 10492",
"description": "2 items",
"amounts": [
{ "currency": "usdt", "amount": "24.90" },
{ "currency": "btc", "amount": "0.00027" }
],
"return_url": "https://example.com/checkout/done",
"cancel_url": "https://example.com/cart",
"metadata": { "cart_id": "c_88213" }
}'
```
```json title="201 Created"
{
"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"`, not `24.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_id` is your idempotency key.** Retrying with the same id and the same payload returns
the original session instead of creating a second one. See
[Sessions](/concepts/sessions#idempotency).
* **Store `session.id` against your order** before you send the customer anywhere. It is how you
reconcile the webhook later.
## 4. Send the customer to the widget [#4-send-the-customer-to-the-widget]
Either open the widget in a popup over your own page, which is the better experience:
```html title="Popup"
```
Or redirect, with no JavaScript at all:
```text title="Redirect"
302 Location: https://my.coinlandexchange.com/pay/3a7f21e8-9c04-4d6b-8e15-7b2a9f3c1d60
```
After paying, the customer lands on your `return_url` with `?receipt=&payment_id=`
appended. Full detail, including the popup-blocked fallback, is in [Widget](/widget).
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 [#5-receive-the-webhook-and-fulfil]
Coinland POSTs to your registered URL, signed with your webhook secret:
```json title="payment.completed"
{
"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:
```js title="Node (Express)"
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") ?? "";
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=").map((s) => s.trim())),
);
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!Number.isFinite(age) || age > 300) return res.sendStatus(400);
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${parts.t}.${req.body.toString("utf8")}`)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(parts.v1 ?? "", "utf8");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
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](/webhooks) covers retries, the `session.expired` event and
what a non-2xx from you causes.
## What to check before you ship [#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_url` alone.
* Your key lives server-side only.
The full list is in [Going live](/go-live).
# Webhooks (/en/webhooks)
Webhooks are how Coinland tells you something happened without you polling for it. They are signed
POSTs to the https URL you register in your business console.
There are three events, and one rule that matters more than any of them: **the payload is a hint, not
a fact**. Read the authoritative record before you act on it.
## Events [#events]
| Type | When | What to do |
| ------------------- | ------------------------------------------- | ---------------------------------------------- |
| `payment.completed` | A session was paid and the transfer settled | Fetch the payment, fulfil the order |
| `session.expired` | A session hit `expires_at` unpaid | Release the cart or reservation |
| `payout.completed` | A [payout or refund](/payouts) settled | Fetch the payout, close the case it belongs to |
```json title="payment.completed"
{
"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"
}
```
```json title="session.expired"
{
"event_id": "5c8b1f47-2a93-4d06-b1e8-7f0c3d9a5b62",
"type": "session.expired",
"session_id": "3a7f21e8-9c04-4d6b-8e15-7b2a9f3c1d60",
"reference_id": "order-10492",
"status": "expired"
}
```
```json title="payout.completed"
{
"event_id": "a17b3e50-9d24-4c81-b6f3-5e0a2c7d1948",
"type": "payout.completed",
"payout_id": "9e3c7a41-0b52-4f18-8d6a-3c7e1f9b40d5",
"reference_id": "payout-2291",
"kind": "payout",
"status": "completed"
}
```
`payout.completed` fires for refunds too -- `kind` is `payout` or `refund`, so branch on it rather than
assuming. Note that a payout you created yourself has already settled by the time your own API call
returned, so this event mostly matters for payouts made from the business console.
New event types are additive and can appear without a version bump, so **always have a default branch**
that ignores what it does not recognise. A handler that throws on an unknown `type` turns a routine,
backwards-compatible addition into an outage on your side.
## Verifying the signature [#verifying-the-signature]
Every request carries:
```text
x-pay-signature: t=1754999071,v1=3b8a5f9c2d1e...
```
* **`t`** is the unix timestamp, in seconds, at which the request was signed.
* **`v1`** is `HMAC-SHA256(webhook_secret, "{t}.{rawBody}")` in lowercase hex.
Note the signed string: the timestamp, a literal dot, then the **raw request body exactly as
transmitted**. Including `t` in what is signed is what stops an old, genuine request from being replayed
with a fresh header.
```ts
import crypto from "node:crypto";
import express from "express";
const app = express();
const webhookSecret = process.env.COINLAND_PAY_WEBHOOK_SECRET!;
// The RAW body is what was signed. A JSON parser that re-serialises the request
// produces different bytes — different key order, different whitespace — and
// every signature fails for reasons that look like a bug in ours. Take the raw
// buffer, verify, then parse.
app.post(
"/webhooks/coinland",
express.raw({ type: "application/json" }),
(req, res) => {
const header = req.get("x-pay-signature") ?? "";
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=").map((s) => s.trim())),
);
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp)) return res.sendStatus(400);
// Five-minute window, checked in BOTH directions so a clock ahead of ours
// is rejected too.
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return res.sendStatus(400);
const expected = crypto
.createHmac("sha256", webhookSecret)
.update(`${timestamp}.${req.body.toString("utf8")}`)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(parts.v1 ?? "", "utf8");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(400);
}
// Acknowledge FIRST, work after: a slow handler is a retried handler.
res.sendStatus(200);
void handleEvent(JSON.parse(req.body.toString("utf8")));
},
);
```
```js
const crypto = require("node:crypto");
const express = require("express");
const app = express();
const webhookSecret = process.env.COINLAND_PAY_WEBHOOK_SECRET;
// The RAW body is what was signed — verify before parsing, never after.
app.post(
"/webhooks/coinland",
express.raw({ type: "application/json" }),
(req, res) => {
const header = req.get("x-pay-signature") || "";
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=").map((s) => s.trim())),
);
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp)) return res.sendStatus(400);
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return res.sendStatus(400);
const expected = crypto
.createHmac("sha256", webhookSecret)
.update(`${timestamp}.${req.body.toString("utf8")}`)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(parts.v1 || "", "utf8");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(400);
}
res.sendStatus(200); // acknowledge first, work after
handleEvent(JSON.parse(req.body.toString("utf8")));
},
);
```
```python
import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
WEBHOOK_SECRET = os.environ["COINLAND_PAY_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/coinland")
def coinland_webhook():
# get_data() is the RAW body. Anything that re-serialises the JSON changes
# the bytes and every signature will fail.
raw = request.get_data()
header = request.headers.get("x-pay-signature", "")
parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
try:
timestamp = int(parts["t"])
except (KeyError, ValueError):
abort(400)
# Five-minute window, both directions.
if abs(time.time() - timestamp) > 300:
abort(400)
expected = hmac.new(
WEBHOOK_SECRET, f"{timestamp}.".encode() + raw, hashlib.sha256
).hexdigest()
# compare_digest is the constant-time comparison. Never use ==.
if not hmac.compare_digest(expected, parts.get("v1", "")):
abort(400)
# Acknowledge fast, work after — hand the event to a queue or a thread.
enqueue_event(request.get_json(force=True))
return "", 200
```
```rust
// Cargo.toml: axum = "0.8", hmac = "0.12", sha2 = "0.10", hex = "0.4"
use axum::{
body::Bytes,
http::{HeaderMap, StatusCode},
};
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac;
pub async fn coinland_webhook(headers: HeaderMap, body: Bytes) -> StatusCode {
let secret = std::env::var("COINLAND_PAY_WEBHOOK_SECRET").unwrap_or_default();
let header = headers
.get("x-pay-signature")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let (mut timestamp, mut signature) = (None, None);
for part in header.split(',') {
match part.trim().split_once('=') {
Some(("t", v)) => timestamp = v.parse::().ok(),
Some(("v1", v)) => signature = Some(v),
_ => {}
}
}
let (Some(timestamp), Some(signature)) = (timestamp, signature) else {
return StatusCode::BAD_REQUEST;
};
// Five-minute window, both directions.
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
if (now - timestamp).abs() > 300 {
return StatusCode::BAD_REQUEST;
}
// `body` is the raw bytes as transmitted — exactly what was signed.
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
mac.update(format!("{timestamp}.").as_bytes());
mac.update(&body);
let Ok(provided) = hex::decode(signature) else {
return StatusCode::BAD_REQUEST;
};
// verify_slice IS the constant-time compare; never compare hex strings.
if mac.verify_slice(&provided).is_err() {
return StatusCode::BAD_REQUEST;
}
// Acknowledge fast, work after.
tokio::spawn(handle_event(body));
StatusCode::OK
}
```
```php
300) {
http_response_code(400);
exit;
}
$expected = hash_hmac(
'sha256',
$timestamp . '.' . $raw,
getenv('COINLAND_PAY_WEBHOOK_SECRET')
);
// hash_equals is the constant-time compare. A === on a signature is a real
// weakness, not a theoretical one.
if (!hash_equals($expected, $parts['v1'] ?? '')) {
http_response_code(400);
exit;
}
http_response_code(200); // acknowledge first
$event = json_decode($raw, true);
enqueue_event($event); // work after
```
Three requirements, all non-negotiable:
1. **Hash the exact raw bytes.** Re-serialising the JSON breaks the signature. Most frameworks need to
be told to hand you the raw body; do that before you do anything else.
2. **Enforce the five-minute window.** Without it, a signature captured once is valid forever.
3. **Compare in constant time.** `crypto.timingSafeEqual`, `hash_equals`, `hmac.compare_digest`.
The same secret signs [receipt tokens](/concepts/receipts), so there is one value to protect and one
primitive to get right.
## Deduplicate on event\_id [#deduplicate-on-event_id]
**Assume every event will arrive more than once.** Retries, a timeout on your side that we could not
tell from a failure, a network partition -- all of them produce a redelivery, and a redelivery is normal
operation rather than an error.
`event_id` is stable across every attempt at the same event. Record it and refuse the second one:
```js
async function handle(event) {
// A unique constraint on event_id is the whole mechanism. Doing this check as
// a SELECT then an INSERT leaves a race two concurrent retries will find.
const inserted = await db
.insert(webhookEvents)
.values({ eventId: event.event_id, type: event.type })
.onConflictDoNothing()
.returning();
if (inserted.length === 0) return; // already handled
if (event.type !== "payment.completed") return;
await fulfil(event);
}
```
Idempotency on your side is what makes the retry policy safe. Without it, one blip double-ships an
order.
## The payload is a hint [#the-payload-is-a-hint]
The body carries ids and a status. It deliberately carries **no amount**, because a webhook body is
something that arrives at your server from the network, and an amount you would act on should come from
a call you made.
So the handler pattern is always the same: verify, dedupe, then read.
```ts
const API = "https://my.coinlandexchange.com";
// The webhook payload is a HINT. This is the authority.
const payment = await fetch(`${API}/api/pay/v1/payments/${event.payment_id}`, {
headers: { Authorization: `Bearer ${process.env.COINLAND_PAY_KEY}` },
}).then((r) => r.json());
if (payment.reference_id !== order.id) return; // not this order
if (payment.currency !== order.currency) return; // not what we quoted
if (new Decimal(payment.amount).lt(order.total)) return; // underpaid
await fulfil(order, payment);
```
```js
const API = "https://my.coinlandexchange.com";
// The webhook payload is a HINT. This is the authority.
const payment = await fetch(`${API}/api/pay/v1/payments/${event.payment_id}`, {
headers: { Authorization: `Bearer ${process.env.COINLAND_PAY_KEY}` },
}).then((r) => r.json());
if (payment.reference_id !== order.id) return; // not this order
if (payment.currency !== order.currency) return; // not what we quoted
if (new Decimal(payment.amount).lt(order.total)) return; // underpaid
await fulfil(order, payment);
```
```python
from decimal import Decimal
import requests
API = "https://my.coinlandexchange.com"
# The webhook payload is a HINT. This is the authority.
payment = requests.get(
f"{API}/api/pay/v1/payments/{event['payment_id']}",
headers={"Authorization": f"Bearer {API_KEY}"},
timeout=15,
).json()
if payment["reference_id"] != order.id:
return # not this order
if payment["currency"] != order.currency:
return # not what we quoted
if Decimal(payment["amount"]) < order.total:
return # underpaid, do not ship
fulfil(order, payment)
```
```rust
const API: &str = "https://my.coinlandexchange.com";
// The webhook payload is a HINT. This is the authority.
let payment: Payment = http
.get(format!("{API}/api/pay/v1/payments/{}", event.payment_id))
.bearer_auth(&api_key)
.send()
.await?
.json()
.await?;
if payment.reference_id != order.id {
return Ok(()); // not this order
}
if payment.currency != order.currency {
return Ok(()); // not what we quoted
}
// Parse as a decimal type (rust_decimal), never f64.
if Decimal::from_str(&payment.amount)? < order.total {
return Ok(()); // underpaid, do not ship
}
fulfil(&order, &payment).await
```
```php
true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('COINLAND_PAY_KEY')],
]);
$payment = json_decode(curl_exec($ch), true);
curl_close($ch);
if ($payment['reference_id'] !== $order->id) { return; } // not this order
if ($payment['currency'] !== $order->currency) { return; } // not what we quoted
// bccomp keeps this out of float territory.
if (bccomp($payment['amount'], $order->total, 8) < 0) { return; } // underpaid
fulfil($order, $payment);
```
That fetch is the one authoritative read in the whole flow. `GET /payments/{id}` and
`GET /sessions/{id}` are the two calls whose answers you can build on.
## Answer fast, work afterwards [#answer-fast-work-afterwards]
Return a 2xx as soon as the signature checks out. Do the fulfilment work after you have responded, or
hand it to a queue.
A handler that ships the goods, sends the email and updates the warehouse before responding is a handler
that will eventually take longer than the delivery timeout. What happens then is that the work succeeds
and the delivery is recorded as failed, so it is retried -- and your idempotency check is now the only
thing standing between one order and two.
## Retries and backoff [#retries-and-backoff]
A delivery is successful if you answer with any 2xx. Anything else -- a 4xx, a 5xx, a timeout, a TLS
failure, a DNS failure -- is a failed attempt, and Coinland retries with exponential backoff over
roughly 24 hours before giving up.
Because deliveries can be hours apart, **do not rely on the webhook as your only path**. Two habits
make an integration robust to a webhook that never lands:
* **Your `return_url` page polls.** The customer is right there and already knows they paid, so read
`GET /sessions/{id}` and show the order as paid the moment it says `completed`.
* **A sweep catches the rest.** Once a day, list your recent sessions with unpaid orders attached and
reconcile them. This also catches an order that was paid while your server was down.
Retries change nothing about correctness if you dedupe. They only change how long a missed delivery
takes to arrive.
## Registering and rotating [#registering-and-rotating]
Set your URL and secret in the business console. Requirements:
* **https only.** A plaintext webhook URL is refused with `MERCHANT_WEBHOOK_URL_INVALID`, along with
anything unparsable or pointed at a private address.
* **Publicly reachable.** We cannot deliver to localhost or an address inside your VPN.
* **One URL.** If you need to fan out to several services, receive once and publish internally.
Rotating the secret changes what signs both your webhooks and your receipt tokens, so accept both the
old and the new value for a short window while the change propagates, then drop the old one.
## Checklist [#checklist]
* Raw body, not a re-serialised one.
* Timestamp window enforced, both directions.
* Constant-time signature comparison.
* Unique constraint on `event_id`.
* 2xx returned before the slow work starts.
* Unknown `type` ignored rather than thrown on.
* Amounts read from `GET /payments/{id}`, never from the payload.
# Widget (/en/widget)
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.
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.
## Three integration levels [#three-integration-levels]
| Level | You write | The customer sees | Use it when |
| ---------------------- | ----------------------- | -------------------------------- | ------------------------------------------------- |
| **1. Hosted redirect** | A 302 to `checkout_url` | Your page, then Coinland's | You want the least code, or you run no JavaScript |
| **2. Popup** | `CoinlandPay.open()` | Your page, with Coinland over it | You want the customer to stay on your page |
| **3. Embedded inline** | `CoinlandPay.mount()` | The order card **in** your page | You 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 [#level-1-hosted-redirect]
No JavaScript at all. The session response carries `checkout_url`; send a 302 to it.
```ts
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);
}
```
```js
const API = "https://my.coinlandexchange.com";
app.get("/checkout/:orderId", async (req, res) => {
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);
});
```
```python
import os
import requests
from flask import redirect
API = "https://my.coinlandexchange.com"
@app.get("/checkout/")
def start_checkout(order_id):
order = load_order(order_id)
session = requests.post(
f"{API}/api/pay/v1/sessions",
headers={
"Authorization": f"Bearer {os.environ['COINLAND_PAY_KEY']}",
"Content-Type": "application/json",
},
json={
"reference_id": order.id,
"title": f"Order {order.number}",
"amounts": [{"currency": "usdt", "amount": order.total_usdt}],
"return_url": "https://example.com/checkout/done",
"cancel_url": "https://example.com/cart",
},
timeout=15,
).json()
# Store the session id against the order BEFORE sending the customer away.
order.update(pay_session_id=session["id"])
return redirect(session["checkout_url"], code=302)
```
```rust
// axum handler. Cargo.toml: axum = "0.8",
// reqwest = { version = "0.12", features = ["json"] }, serde = { version = "1" }
use axum::response::Redirect;
const API: &str = "https://my.coinlandexchange.com";
pub async fn start_checkout(
state: AppState,
order_id: String,
) -> Result {
let order = state.load_order(&order_id).await?;
let session: Session = state
.http
.post(format!("{API}/api/pay/v1/sessions"))
.bearer_auth(&state.pay_key)
.json(&serde_json::json!({
"reference_id": order.id,
"title": format!("Order {}", order.number),
"amounts": [{ "currency": "usdt", "amount": order.total_usdt }],
"return_url": "https://example.com/checkout/done",
"cancel_url": "https://example.com/cart",
}))
.send()
.await?
.error_for_status()?
.json()
.await?;
// Store the session id against the order BEFORE sending the customer away.
state.attach_session(&order.id, &session.id).await?;
Ok(Redirect::to(&session.checkout_url))
}
```
```php
true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('COINLAND_PAY_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'reference_id' => $order->id,
'title' => 'Order ' . $order->number,
'amounts' => [['currency' => 'usdt', 'amount' => $order->total_usdt]],
'return_url' => 'https://example.com/checkout/done',
'cancel_url' => 'https://example.com/cart',
]),
]);
$session = json_decode(curl_exec($ch), true);
curl_close($ch);
// Store the session id against the order BEFORE sending the customer away.
$order->update(['pay_session_id' => $session['id']]);
header('Location: ' . $session['checkout_url'], true, 302);
exit;
```
The customer pays, comes back to your `return_url` with `?receipt=&payment_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 [#the-embed-script]
Levels 2 and 3 need one script tag, no build step and no bundle:
```html
```
It defines a single global, `CoinlandPay`, and pulls in nothing else. Load it with `defer` in your
`` or at the end of ``.
## Level 2: popup [#level-2-popup]
```js
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.
},
});
```
`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 [#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.
```html
```
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=&payment_id=`
appended.
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 [#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 [#handling-the-result]
Whichever level you use, the result arrives the same way.
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](/webhooks) or from `GET /payments/{id}`,
which are the only two things a customer cannot influence.
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:
```js
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 [#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 [#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.
# Payments (/en/concepts/payments)
A payment is what a completed session leaves behind: an immutable record of value that moved from one
Coinland account to another. It is the authoritative object in this API, and the thing every webhook
tells you to come and read.
## The internal transfer model [#the-internal-transfer-model]
Both sides of a Coinland Pay payment are Coinland accounts -- the customer's, and yours. So the
payment is a debit and a credit in Coinland's own ledger, posted together:
```text
customer balance ── amount ──▶ Coinland fee (fee_amount)
──▶ your balance (net_amount)
```
Nothing touches a blockchain. That has four consequences worth designing around:
* **It settles in one hop.** There is no pending, no confirmation count, no mempool. By the time the
widget tells the customer it worked, the money is in your balance and `GET /payments/{id}` will
answer.
* **There is no network fee, at any size.** A 4 USDT payment costs the same to move as a 4,000 USDT
one, which makes small payments viable in a way an on-chain rail does not.
* **It cannot be reversed.** No chargebacks, and no reverse call in this API. A refund is a transfer
you send back, at your discretion, from your own balance.
* **Only completed payments exist here.** There is no "pending payment" object to poll. A session that
has not been paid simply has `payment: null`, and the transfer either happens atomically or does not
happen at all.
Payments credit a **dedicated business wallet**, kept separate from your personal spot and trading
balance so a business's takings do not mix with personal funds. It is the wallet to look in when you
are checking whether a payment arrived.
## Moving your takings out [#moving-your-takings-out]
Your business wallet holds what you have been paid; it is not itself a payout surface. Getting money
out is two steps, and the second one is whatever you already do:
**Transfer business to main**, in the business console. This is a move between two of our own ledgers,
so it is **instant and free** — no network fee, no venue, no waiting.
**Trade or withdraw from your main wallet**, exactly as before. Nothing about that flow changed.
You cannot withdraw straight out of the business wallet, and that absence is the design rather than
a gap: it keeps one withdrawal flow, the one you already know, instead of adding a second payout
surface with its own rules. Transfer first, then withdraw.
## Amount, fee and net [#amount-fee-and-net]
If you already reconcile on `amount`, read this. `amount` is the figure **you priced**. What the
customer was actually debited is `charged_amount`, and under `fee_bearer: "customer"` it is larger.
For your own books `net_amount` was always the right field and still is; for "what did my customer
pay", switch to `charged_amount`.
Four amounts and the flag that ties them together. Confusing them is the most common reconciliation
bug on any payment rail:
## Who bears the fee [#who-bears-the-fee]
You choose, per business, whether you absorb Coinland's fee or add it to the customer's total. One
identity holds in **both** configurations, and it is the whole accounting model:
```text
charged_amount - net_amount == fee_amount
```
| `fee_bearer` | The customer is debited | You receive |
| ------------ | --------------------------------------- | ----------------------------------- |
| `merchant` | `charged_amount == amount` | `net_amount == amount - fee_amount` |
| `customer` | `charged_amount == amount + fee_amount` | `net_amount == amount` |
So reconcile your **order totals against `amount`**, your **books against `net_amount`**, and any
"what did the customer pay" question against **`charged_amount`**. Using one field for all three is
what makes a ledger drift.
## Where your fee rate comes from [#where-your-fee-rate-comes-from]
Your rate is not a fixed number and you cannot set it. It comes from a **tier ladder** that moves
automatically on your rolling 30-day volume and payment count — trade more and you move down the
ladder by yourself. Your current rung and your progress toward the next one are shown in the business
console.
### Your rate is fixed when the session is created [#your-rate-is-fixed-when-the-session-is-created]
The effective rate and the fee bearer are frozen onto a **checkout session at creation**, and the
confirm settles on that snapshot. The consequence worth designing around:
> A rate change takes effect on your **next** session, never on one that is already open.
That matters because the ladder moves on its own, on a nightly sweep, while sessions can live up to
24 hours. Without the freeze, the total shown in the widget and the amount eventually debited could
disagree. With it, a customer looking at an open checkout pays the terms they were quoted.
It is the same guarantee the coin quotes on a [USD-priced session](/concepts/sessions) already carry,
over the same window — the session's own `expires_at`. One deadline, not two.
Because the terms are already fixed by the time money moves, a rate change never rewrites history
either: an old payment's `fee_amount` is settled forever, and a report you ran last month gives the
same answer today.
All four amounts are decimal strings, and they should stay strings all the way into your database.
Parse them with a decimal type, never a float -- see [going live](/go-live#money-handling).
## Holding only stablecoin [#holding-only-stablecoin]
You can opt in, per business, to have every coin you receive **automatically sold into USDT**, so
your balance stays in one asset instead of accumulating whatever your customers paid with. It is off
by default; you turn it on in the business console. USDT is currently the only permitted target —
the list is operator-controlled, not something you pick from.
What happens after a payment settles:
* Coinland moves that payment's `net_amount` from your business wallet to your spot wallet, places an
ordinary **spot market sell** there on your behalf, and moves the proceeds back to the business
wallet.
* It runs on a periodic sweep shortly after settlement, and the sell settles asynchronously — so there
is an in-progress state before the USDT lands back. It is **not instant**, and not on a guaranteed
interval.
It executes at the **live market price** and pays the **normal spot trading commission**. It is not
free, and it is not rate-locked: the [quotes on a USD-priced session](/concepts/sessions) and your
[fee terms](#your-rate-is-fixed-when-the-session-is-created) are both fixed when the session is
created, but this sell happens afterwards, at whatever the market is then. Nothing about the
conversion is quoted in advance.
Three cases are left alone rather than converted, and in each the coin simply stays in your balance:
| Case | What happens |
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| The payment already arrived in your target coin | Nothing to sell |
| The amount is below the market's minimum | Left unconverted as dust, immediately -- waiting cannot help |
| The market is unavailable, or the sell fails | Retried every 15 minutes, up to 5 attempts, then given up on permanently -- and the coin is returned to your business wallet |
This is the property to take away. The payment is final the moment it settles, and **a payment's
correctness never depends on a market being open**. A conversion that is skipped, fails, or never
runs leaves the payment `completed` and the money yours — you simply hold the original coin. There
is no reversal and no retro-charge.
Nothing notifies you either way: there is no email and no webhook for a conversion. The outcome and
its reason (`already-stable`, `below-min`, `market-unavailable`, `target-invalid`,
`attempts-exhausted`) are recorded in the business console, which is where to look if a coin you
expected to be converted is still sitting in your balance.
The dust threshold is the trading pair's own market minimum and is **not published on any
merchant-facing surface** — you cannot compute in advance whether a given payment will convert. What
you get is the outcome, recorded as `below-min`.
Because the sell is an ordinary order in your own account, it appears in your normal trading history
alongside anything else you trade, which is where its commission is reconcilable. Each conversion
record also carries the `order_id` of the trade it became, so a specific payment can be tied to a
specific trade. Note that this fee is the **trading** commission and is entirely separate from
`fee_amount`, which is Coinland's fee on the payment itself.
The setting is configured and read in the business console. It is deliberately **not** part of the
merchant API: nothing in `GET /me` or on a payment object reports it, because a conversion is
something that happens to your balance afterwards rather than a property of the payment.
## Receipt numbers [#receipt-numbers]
Every payment gets a human-friendly receipt number alongside its UUID:
```text
CLP-8F3K2M9Q
```
It is designed to be said out loud and typed by hand: it is what a customer will quote in a support
email, and what your support agent will paste into a search box. Unlike the UUID, it is short enough
to survive that trip.
It is also a **lookup id**. `GET /api/pay/v1/payments/{id}` takes either:
```bash
# By UUID
curl .../api/pay/v1/payments/b92e4d17-6c38-4a05-9f2b-1e7d3c8a5049 \
-H "Authorization: Bearer $COINLAND_PAY_KEY"
# By receipt number -- same record
curl .../api/pay/v1/payments/CLP-8F3K2M9Q \
-H "Authorization: Bearer $COINLAND_PAY_KEY"
```
A receipt number is not a secret and not a credential. Knowing one proves nothing on its own, which is
what the signed [receipt token](/concepts/receipts) is for.
## Listing payments [#listing-payments]
`GET /api/pay/v1/payments` returns your payments newest first, cursor-paginated:
```bash
curl "https://my.coinlandexchange.com/api/pay/v1/payments?limit=100¤cy=usdt&from=2026-08-01T00:00:00Z" \
-H "Authorization: Bearer $COINLAND_PAY_KEY"
```
```json
{
"data": [
{
"id": "b92e4d17-6c38-4a05-9f2b-1e7d3c8a5049",
"receipt_no": "CLP-8F3K2M9Q",
"session_id": "3a7f21e8-9c04-4d6b-8e15-7b2a9f3c1d60",
"reference_id": "order-10492",
"status": "completed",
"currency": "usdt",
"amount": "24.90",
"charged_amount": "24.90",
"fee_amount": "0.12",
"fee_bearer": "merchant",
"net_amount": "24.78",
"metadata": { "cart_id": "c_88213" },
"receipt": "v1.eyJwYXltZW50X2lkIjoi...",
"paid_at": "2026-08-11T12:04:31Z"
}
],
"next_cursor": "eyJwYWlkX2F0IjoiMjAyNi0wOC0xMVQxMjowNDozMVoifQ"
}
```
Page by following `next_cursor` until it comes back `null`. Do not count pages or assume a page size:
pass the cursor you were given, and stop when there is not one.
`from` and `to` bound `paid_at`, and `currency` filters to one coin. Together they are how you build a
daily settlement report:
```ts
const API = "https://my.coinlandexchange.com";
export async function* allPayments(params: Record) {
let cursor: string | null = null;
do {
const query = new URLSearchParams({ ...params, limit: "100" });
if (cursor) query.set("cursor", cursor);
const page = await fetch(`${API}/api/pay/v1/payments?${query}`, {
headers: { Authorization: `Bearer ${process.env.COINLAND_PAY_KEY}` },
}).then((r) => r.json());
yield* page.data;
cursor = page.next_cursor; // stop when it comes back null
} while (cursor);
}
```
```js
const API = "https://my.coinlandexchange.com";
async function* allPayments(params) {
let cursor = null;
do {
const query = new URLSearchParams({ ...params, limit: "100" });
if (cursor) query.set("cursor", cursor);
const page = await fetch(`${API}/api/pay/v1/payments?${query}`, {
headers: { Authorization: `Bearer ${process.env.COINLAND_PAY_KEY}` },
}).then((r) => r.json());
yield* page.data;
cursor = page.next_cursor; // stop when it comes back null
} while (cursor);
}
```
```python
import os
import requests
API = "https://my.coinlandexchange.com"
AUTH = {"Authorization": f"Bearer {os.environ['COINLAND_PAY_KEY']}"}
def all_payments(**params):
cursor = None
while True:
query = {**params, "limit": 100}
if cursor:
query["cursor"] = cursor
page = requests.get(
f"{API}/api/pay/v1/payments", params=query, headers=AUTH, timeout=30
).json()
yield from page["data"]
cursor = page.get("next_cursor")
if not cursor: # stop when it comes back null
break
```
```rust
const API: &str = "https://my.coinlandexchange.com";
#[derive(serde::Deserialize)]
struct Page {
data: Vec,
next_cursor: Option,
}
pub async fn all_payments(
http: &reqwest::Client,
api_key: &str,
currency: &str,
) -> reqwest::Result> {
let mut out = Vec::new();
let mut cursor: Option = None;
loop {
let mut req = http
.get(format!("{API}/api/pay/v1/payments"))
.bearer_auth(api_key)
.query(&[("limit", "100"), ("currency", currency)]);
if let Some(c) = &cursor {
req = req.query(&[("cursor", c)]);
}
let page: Page = req.send().await?.error_for_status()?.json().await?;
out.extend(page.data);
// Stop when it comes back null.
match page.next_cursor {
Some(c) => cursor = Some(c),
None => break,
}
}
Ok(out)
}
```
```php
100]);
if ($cursor !== null) {
$query['cursor'] = $cursor;
}
$ch = curl_init($api . '/api/pay/v1/payments?' . http_build_query($query));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => $auth,
]);
$page = json_decode(curl_exec($ch), true);
curl_close($ch);
yield from $page['data'];
$cursor = $page['next_cursor'] ?? null; // stop when it comes back null
} while ($cursor !== null);
}
```
## Reconciling [#reconciling]
Payments carry your `reference_id`, so reconciliation does not need any id you did not choose:
1. Read a day's payments with `from`/`to`.
2. Match each `reference_id` to an order in your system.
3. Assert `amount` equals what you charged for that order in that coin.
4. Sum `net_amount` per coin, and compare against the credits on your **business wallet**.
An order with no payment was never paid. A payment with no order is the one to investigate, and it
almost always means a `reference_id` was reused or generated somewhere you did not expect.
# Receipts (/en/concepts/receipts)
Every completed payment carries a **receipt token**: a short, self-contained string that proves the
payment happened, on terms only you can check. It is the answer to "the customer says they paid, and I
have no way to know".
```text
v1.eyJwYXltZW50X2lkIjoiYjkyZTRkMTctNmMzOC00YTA1LTlmMmItMWU3ZDNjOGE1MDQ5Iiwic
mVjZWlwdF9ubyI6IkNMUC04RjNLMk05USIsIm1lcmNoYW50X2lkIjoiOGYxYzlhMzQtM2QyZS00Y
jE3LTlmMGEtMmM2ZDViOGU0YTcxIiwicmVmZXJlbmNlX2lkIjoib3JkZXItMTA0OTIiLCJjdXJyZ
W5jeSI6InVzZHQiLCJhbW91bnQiOiIyNC45MCIsImNoYXJnZWRfYW1vdW50IjoiMjQuOTAiLCJmZ
WVfYmVhcmVyIjoibWVyY2hhbnQiLCJuZXRfYW1vdW50IjoiMjQuNzgiLCJwYWlkX2F0IjoiMjAyN
i0wOC0xMVQxMjowNDozMVoifQ.k7Qw3xR2mB9pLd4vN8sYc1TfHj0aXeU6ZgO5rWq
```
You receive one in three places: on the `payment` object as `receipt`, appended to your `return_url`
as `?receipt=...`, and inside the token the customer can copy from their own payment history.
## The format [#the-format]
Three dot-separated parts, and the middle one is the whole payload:
```text
v1..
```
* **`v1`** is the format version. Refuse a token whose first part you do not recognise rather than
guessing at it.
* **The payload** is plain JSON, base64url-encoded without padding. It is not encrypted -- anyone can
read it. That is intentional: the token is proof, not a secret.
* **The signature** is an HMAC-SHA256 over the literal string `"v1." + `, keyed with your
**webhook secret**, then base64url-encoded.
Note what is signed: the first two parts joined, including the `v1.` prefix and the dot. Signing the
payload alone would let someone move a valid signature onto a future format version.
### Payload fields [#payload-fields]
The payload is signed as serialised, so anyone rebuilding it from parts must emit the keys in the
canonical order: `payment_id`, `receipt_no`, `merchant_id`, `reference_id`, `currency`, `amount`,
`charged_amount`, `fee_bearer`, `net_amount`, `paid_at`. Verifying a token you were GIVEN needs none
of this — you hash the string as received.
There is no expiry claim. A receipt is a record of something that happened, and it stays true; if you
need recency, compare `paid_at` yourself.
## Why it cannot be forged [#why-it-cannot-be-forged]
The signature is an HMAC keyed with your webhook secret, and that secret exists in exactly two places:
Coinland's encrypted store, and your server. It is never shown to a customer, never sent to a browser,
and never part of the token.
So a customer -- or anyone who has seen a hundred valid receipts -- can decode a payload, edit
`amount` to something larger, and re-encode it. What they cannot do is produce a signature that matches
the edited payload, because computing one requires the key. Your verification rejects it in the same
line of code that would have accepted a real one.
This is the same construction as the [webhook signature](/webhooks), with the same secret, which means
one secret to rotate and one primitive to get right.
## Verifying offline [#verifying-offline]
Offline is the recommended path. It is a few lines of standard-library code, it costs no network hop,
and it works when Coinland is unreachable.
```ts
import crypto from "node:crypto";
/**
* Returns the payload if the token is authentic, or null.
* `secret` is your webhook secret.
*/
export function verifyReceipt(
token: string,
secret: string,
expectedMerchantId: string,
): Record | null {
const parts = token.split(".");
if (parts.length !== 3 || parts[0] !== "v1") return null;
const [version, body, signature] = parts;
const expected = crypto
.createHmac("sha256", secret)
.update(`${version}.${body}`)
.digest("base64url");
// Constant-time: a comparison that returns early leaks how much of a guess
// was right, which is enough to reconstruct a signature.
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(signature, "utf8");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
const payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
// A signature only proves the token came from a Coinland secret. Checking the
// merchant id is what proves it came from YOURS.
if (payload.merchant_id !== expectedMerchantId) return null;
return payload;
}
```
```js
const crypto = require("node:crypto");
/** Returns the payload if the token is authentic, or null. */
function verifyReceipt(token, secret, expectedMerchantId) {
const parts = token.split(".");
if (parts.length !== 3 || parts[0] !== "v1") return null;
const [version, body, signature] = parts;
const expected = crypto
.createHmac("sha256", secret)
.update(`${version}.${body}`)
.digest("base64url");
// Constant-time comparison, never ===.
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(signature, "utf8");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
const payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
if (payload.merchant_id !== expectedMerchantId) return null;
return payload;
}
module.exports = { verifyReceipt };
```
```python
import base64
import hashlib
import hmac
import json
def _b64url_decode(value: str) -> bytes:
# base64url with the padding stripped: put it back before decoding.
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
def verify_receipt(token: str, secret: str, expected_merchant_id: str):
"""Returns the payload dict if the token is authentic, or None."""
parts = token.split(".")
if len(parts) != 3 or parts[0] != "v1":
return None
version, body, signature = parts
raw = hmac.new(
secret.encode(), f"{version}.{body}".encode(), hashlib.sha256
).digest()
expected = base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
# compare_digest is the constant-time comparison. Never use ==.
if not hmac.compare_digest(expected, signature):
return None
payload = json.loads(_b64url_decode(body))
# A valid signature proves SOME Coinland secret signed it; the merchant id
# is what proves it was yours.
if payload.get("merchant_id") != expected_merchant_id:
return None
return payload
```
```rust
// Cargo.toml: hmac = "0.12", sha2 = "0.10", base64 = "0.22", serde_json = "1"
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac;
/// Returns the payload if the token is authentic, or None.
pub fn verify_receipt(
token: &str,
secret: &str,
expected_merchant_id: &str,
) -> Option {
let mut parts = token.split('.');
let (version, body, signature) = (parts.next()?, parts.next()?, parts.next()?);
if parts.next().is_some() || version != "v1" {
return None;
}
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).ok()?;
mac.update(version.as_bytes());
mac.update(b".");
mac.update(body.as_bytes());
// verify_slice IS the constant-time compare — do not decode to a String
// and use ==.
let provided = URL_SAFE_NO_PAD.decode(signature).ok()?;
mac.verify_slice(&provided).ok()?;
let payload: serde_json::Value =
serde_json::from_slice(&URL_SAFE_NO_PAD.decode(body).ok()?).ok()?;
// The signature proves a Coinland secret signed it; the merchant id proves
// it was yours.
if payload.get("merchant_id")?.as_str()? != expected_merchant_id {
return None;
}
Some(payload)
}
```
```php
Three things to get right in any language:
1. **base64url, not base64.** `-` and `_` replace `+` and `/`, and the `=` padding is stripped. A
plain base64 decoder will fail on some tokens and succeed on others, which is the worst kind of bug
to have in a payment path.
2. **Constant-time comparison.** `crypto.timingSafeEqual`, `hash_equals`, `hmac.compare_digest` --
whatever your standard library calls it. A short-circuiting `==` on a signature is a real, exploited
weakness, not a theoretical one.
3. **Check `merchant_id`.** A valid signature proves the token was minted under a Coinland webhook
secret. Comparing the merchant id is what proves it was minted under yours and not another
business's.
## Verifying over the API [#verifying-over-the-api]
If you would rather not implement HMAC, `POST /api/pay/v1/receipts/verify` does it for you. It checks
the signature against your secret **and** that a matching payment still exists with those facts, so it
catches one thing offline verification cannot: a correctly signed token for a payment that was later
found to be something other than it claimed.
```bash
curl -X POST https://my.coinlandexchange.com/api/pay/v1/receipts/verify \
-H "Authorization: Bearer $COINLAND_PAY_KEY" \
-H "Content-Type: application/json" \
-d '{"receipt":"v1.eyJwYXltZW50X2lkIjoi....k7Qw3xR2mB9pLd4vN8sYc1TfHj0aXeU6ZgO5rWq"}'
```
```json title="200 OK"
{
"valid": true,
"payment": {
"id": "b92e4d17-6c38-4a05-9f2b-1e7d3c8a5049",
"receipt_no": "CLP-8F3K2M9Q",
"reference_id": "order-10492",
"currency": "usdt",
"amount": "24.90",
"charged_amount": "24.90",
"fee_bearer": "merchant",
"net_amount": "24.78",
"paid_at": "2026-08-11T12:04:31Z"
}
}
```
A bad signature, an altered payload and an unknown payment all answer `PAY_RECEIPT_INVALID` (422) --
one code, because the remedy is the same in every case and distinguishing them would tell an attacker
which half of their forgery was working.
The endpoint is a convenience, not the authority. It reads the same records offline verification
reasons about, so a token that verifies offline against your own secret is already proven; the network
call adds the existence check, not the trust.
## Proof is not fulfilment [#proof-is-not-fulfilment]
A verified receipt tells you a payment happened. It does not tell you that this order has not already
been fulfilled, and it is presented by whoever is holding it. Release goods from your
[webhook](/webhooks) handler or from `GET /payments/{id}`, keyed on your own order state.
The distinction matters because a receipt is portable by design. The same token can be shown twice, or
by someone the customer forwarded it to. Nothing about that is a flaw -- it is what makes a receipt
useful -- but it means the token answers "was this paid?" and never "should I ship this?".
Good uses for a receipt:
* **Support.** A customer pastes their token; your agent verifies it in one function call and knows
immediately whether to believe them, without querying Coinland.
* **Downstream systems.** A fulfilment service or a partner can check a payment without holding your
API key, because verification needs only the webhook secret and no network access.
* **Records.** Store the token with your order. Years later it still proves what was paid, even if the
API has moved on.
What a receipt should never be:
* The thing your `return_url` page trusts to mark an order paid.
* A bearer token for anything. It grants nothing; it attests.
# Sessions (/en/concepts/sessions)
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 [#lifecycle]
```text
┌── 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 [#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 [#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.
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 [#pricing-in-multiple-coins]
`amounts` is a list of `{currency, amount}` pairs, up to ten, and the customer picks exactly one.
```json
"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.
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 [#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.
```json
{
"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.
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 [#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]
`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 [#reading-a-session]
```ts
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();
}
```
```js
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.
async function getSession(id) {
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.
async function cancelSession(id) {
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();
}
```
```python
import os
from urllib.parse import quote
import requests
API = "https://my.coinlandexchange.com"
AUTH = {"Authorization": f"Bearer {os.environ['COINLAND_PAY_KEY']}"}
def get_session(session_id: str):
"""`session_id` may be the UUID or your own reference_id."""
res = requests.get(
f"{API}/api/pay/v1/sessions/{quote(session_id)}", headers=AUTH, timeout=15
)
res.raise_for_status()
return res.json()
def cancel_session(session_id: str):
"""Idempotent: cancelling a cancelled session returns it unchanged."""
res = requests.post(
f"{API}/api/pay/v1/sessions/{quote(session_id)}/cancel",
headers=AUTH,
timeout=15,
)
res.raise_for_status()
return res.json()
```
```rust
// Cargo.toml: percent-encoding = "2"
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
const API: &str = "https://my.coinlandexchange.com";
/// `id` may be the session UUID or your own reference_id.
pub async fn get_session(
http: &reqwest::Client,
api_key: &str,
id: &str,
) -> reqwest::Result {
let id = utf8_percent_encode(id, NON_ALPHANUMERIC);
http.get(format!("{API}/api/pay/v1/sessions/{id}"))
.bearer_auth(api_key)
.send()
.await?
.error_for_status()?
.json()
.await
}
/// Idempotent: cancelling a cancelled session returns it unchanged.
pub async fn cancel_session(
http: &reqwest::Client,
api_key: &str,
id: &str,
) -> reqwest::Result {
let id = utf8_percent_encode(id, NON_ALPHANUMERIC);
http.post(format!("{API}/api/pay/v1/sessions/{id}/cancel"))
.bearer_auth(api_key)
.send()
.await?
.error_for_status()?
.json()
.await
}
```
```php
true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => $auth,
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status < 200 || $status >= 300) {
throw new RuntimeException("coinland pay {$status}");
}
return json_decode($raw, true);
}
/** Idempotent: cancelling a cancelled session returns it unchanged. */
function cancel_session(string $id): array
{
global $api, $auth;
$ch = curl_init($api . '/api/pay/v1/sessions/' . rawurlencode($id) . '/cancel');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => $auth,
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status < 200 || $status >= 300) {
throw new RuntimeException("coinland pay {$status}");
}
return json_decode($raw, true);
}
```
This is the authoritative state. When `status` is `completed`, the full
[payment](/concepts/payments) 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.
# About the API (/en/reference)
The pages in this section are generated directly from the Coinland Pay OpenAPI document, so they
describe exactly what each endpoint accepts and returns. The document itself is published at
[/openapi.json](/openapi.json) if you want to generate a client, a mock server or types from it.
These pages are generated from `docs/openapi/pay.v1.yaml`, whose contract text is English. Field
names, error codes and paths are English regardless of locale and are never translated; the
conceptual guides, which are translated, are what explain them.
What is documented here is what your integration calls. Setup is not: your API keys, your accepted
coins, your branding and your webhook URL are all managed in the
[business console](https://my.coinlandexchange.com/business), which is the only surface with the whole
lifecycle for any of them, so you will not find endpoints for them here.
## Base URL [#base-url]
```text
https://my.coinlandexchange.com
```
Every Pay endpoint sits under `/api/pay/v1`. There is one host and one environment: a session you create
is a session a real customer can pay. See [Going live](/go-live) for how to rehearse safely.
## Authentication [#authentication]
One header on every request:
```text
Authorization: Bearer clpay_live_<64 hex>
```
Keys are minted and revoked in the business console and shown once at creation. Any auth-layer refusal --
a missing header, a malformed key, a revoked key, an unknown key -- answers the single code
`UNAUTHORIZED` (401), so a caller cannot probe which part was wrong.
## Conventions [#conventions]
Requests and responses are JSON; send `Content-Type: application/json` on anything with a body.
**Amounts are decimal strings.** `"24.90"`, never `24.9`. This holds for `amount`, `fee_amount` and
`net_amount`, in requests and responses alike. Keep them as strings until they reach a decimal type in
your own code.
**Timestamps are RFC 3339, UTC.** `expires_at`, `created_at`, `paid_at`, and the `from`/`to` filters.
**Ids come in two shapes, and both are usable as lookup keys.** Sessions and payments carry UUIDs, but
`GET /sessions/{id}` also accepts your own `reference_id`, and `GET /payments/{id}` also accepts a
receipt number (`CLP-...`). You can therefore read either object without having stored anything Coinland
generated.
**Lists are cursor-paginated, newest first.** Pass `limit` and `cursor`, then follow `next_cursor` until
it comes back `null`. Do not count pages or assume a page size.
**Idempotency lives on `reference_id`.** There is no `Idempotency-Key` header. Retrying
`POST /sessions` with the same `reference_id` and an identical payload returns the original session; a
different payload with the same id is refused with `PAY_DUPLICATE_REFERENCE`. See
[Sessions](/concepts/sessions#idempotency).
**A not-found and a not-yours answer identically.** `PAY_SESSION_NOT_FOUND` (404) covers both, because a
different answer for "exists but belongs to another business" would let anyone with a key enumerate other
businesses' orders.
## Stability [#stability]
The API is versioned in the path at `/v1`. Within a version, changes are additive: new endpoints, new
optional request fields and new response properties can appear without notice, and existing fields do not
change meaning or disappear. Anything incompatible ships as a new version, and `/v1` keeps working.
Because new response properties can appear at any time, a deserializer configured to throw on unknown
keys will break on a routine, backwards-compatible change. Configure yours to ignore what it does not
recognise.
The same applies to enumerated values. New error codes and new webhook event types are additive, so
always have a default branch.
## Reliability [#reliability]
Retry a timeout with the same `reference_id`; that is what idempotency is for. Back off with jitter on a
`429`. Every failure uses one envelope -- see [Errors](/errors).
## Where to start [#where-to-start]
[Sessions](/reference/sessions) is the endpoint set every integration needs. `GET /me` is the first call
worth making, because it tells you which coins you may price in.
# Account (/en/reference/account)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Payments (/en/reference/payments)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Payouts (/en/reference/payouts)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Sessions (/en/reference/sessions)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}