Coinland PayDocs

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/verify alike, since that endpoint accepts the same two keys. Receipts issued after the rotation are unaffected, and old payments stay provable through GET /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/24 or 2001: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:

URLWhereWhy
Webhook URLBusiness consoleA plaintext webhook is refused with MERCHANT_WEBHOOK_URL_INVALID. It carries payment ids over the open internet
return_urlPer sessionIt carries a receipt token in the query string
cancel_urlPer sessionConsistency, 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, 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.
  • 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 customer block must come from your customer database: the real internal user id, the real Latin full name (plus native_name whenever 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.review and 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_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, 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.

On this page