Going live
Key hygiene, https requirements, money handling, and the checklist to walk before you take a real payment
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
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. 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 signing secrets are credentials of a different kind: they verify what we send you rather than authenticate what you send us. There are two, deliberately.
- The webhook signing secret verifies deliveries. Rotating it is cheap -- the previous value keeps working for 24 hours and deliveries in that window carry both signatures, so you deploy the replacement whenever suits you.
- The receipt signing secret signs receipt tokens. Rotating it is
retroactive: after its 24-hour window, every receipt issued under the OLD secret stops verifying —
offline and through
POST /receipts/verifyalike, since that endpoint accepts the same two keys. Receipts issued after the rotation are unaffected, and old payments stay provable throughGET /payments/{id}.
Both are readable from your console whenever you need them -- there is no need to destroy a working key in order to see it. Store them server-side only, exactly like an API key.
Pinning a key to an IP address
Any key can be restricted to a list of addresses. You edit that list in the business console, on the same key table.
- Each entry is an exact IP address or a CIDR range -- IPv4 and IPv6 both. For example
203.0.113.7,198.51.100.0/24or2001:db8::/32. - An empty list means no restriction, and that is the default. A key with no list works from anywhere.
- The moment you save even one entry, the key works only from those addresses. A request from any
other address is refused with
UNAUTHORIZED-- the same answer an unknown or revoked key gets, so the response never reveals which addresses would have worked. - A malformed entry is refused at save time with
MERCHANT_KEY_IP_INVALID, rather than later on a live call.
Leave the list empty if you run on serverless
On serverless platforms your outbound address is not stable and changes between calls. Pinning one there takes your gateway down. This is for servers with a known, fixed egress address.
The restriction is a second layer, not a replacement for the key: the key still has to stay secret and server-side.
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
Never parse an amount into a float
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,Decimalfrom a library, or an integer count of the coin's smallest unit. - Store
amount,fee_amountandnet_amountas strings or decimals in your database. Afloatcolumn is a slow-motion reconciliation bug. - Reconcile order totals against
amountand your books againstnet_amount. They differ by Coinland's fee, and using one for both is what makes a ledger drift. See Payments. - Compare amounts with a decimal comparison, never
==on parsed numbers.
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 receipt signing 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.
Customer identity
Every session names one of your customers, and the first payment
binds the payer's Coinland account to that customer.id permanently. Three things to
settle before launch, none of them code-only:
- Send real identities, never placeholders. The
customerblock must come from your customer database: the real internal user id, the real Latin full name (plusnative_namewhenever you hold it), the real email. A"guest"or"test-user"value is not a shortcut -- it binds a real Coinland account to the wrong identity forever, and the only way out is a revoke and a fresh ceremony. If your checkout has a guest flow, create the customer record first and send that. - Subscribe to
binding.reviewand route it to a person. A binding in review is a customer who tried to pay and was refused; until someone approves or rejects it, their money cannot arrive. An unwatched review queue is silent lost revenue. - Decide who in your business reviews pending bindings. The queue lives in the Customers tab of
your business console and behind
POST /customers/{id}/binding/review-- pick the owner, and give them the rule they apply: approve when the name discrepancy is explainable, reject when it is not.
Before your first payout
Only if you send money out. 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
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_urlpage pollsGET /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, 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
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 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.
- 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.