Payouts and refunds
Sending money to a Coinland customer, refunding a payment, and the separate key class both require
Coinland Pay moves money in two directions. Taking a payment is a checkout session 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.
A payout is synchronous. 201 means the money is gone
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
Payouts need their own key class. Your checkout key cannot call them, and a payout key cannot create sessions.
clpay_live_<64 hex> the CHECKOUT class -- sessions, payments, receipts
clpay_payout_<64 hex> the PAYOUT class -- payouts, refundsMint 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.
Why the split is worth the second credential
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
Payouts are switched off until Coinland turns them on for your business, and turning them on means two things, not one:
- Payouts are enabled for your account.
- 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
Every payment object carries a payer_id: an opaque handle for the customer who paid, scoped to your
business.
{
"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.
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"
}'{
"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
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.
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"}'{
"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.
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.
Every recipient failure is one code
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
A refund returns coin to the customer who paid you. Address it by payment id or by receipt number, and send only an amount:
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 isPAY_REFUND_EXCEEDS_PAYMENT(422), and the error reports how much has already been refunded. - Refunds are free.
fee_amountis"0"anddebited_amountequalsamount, 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
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.
recipient receives amount
your wallet pays debited_amount == amount + fee_amountdebited_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
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
reference_id is your idempotency key, and it works exactly as it does on sessions:
- The same
reference_idwith an identical payload returns the original payout, with201. No second transfer happens. - The same
reference_idwith a different payload isPAY_DUPLICATE_REFERENCE(409).
// 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
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:
{
"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 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
| 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 page.
Checklist
- Payout key minted separately, stored separately from the checkout key.
payer_idpersisted against your own customer records at payment time.reference_idderived from something stable in your system, never a random value per attempt.- Timeouts retried with the identical request, never a fresh id.
amountanddebited_amountboth stored, as decimal strings.- A human confirms
masked_namebefore any email-path payout. PAY_PAYOUT_LIMIThandled as a business condition, not a bug -- someone should be told.