Errors
One envelope for every failure, and what each PAY_ and MERCHANT_ code means
Every non-2xx response from Coinland Pay uses the same shape:
{
"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
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
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.
Payouts and refunds
These come from the payout direction 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.
Customer identity binding
These come from payer identity binding: the mandatory customer block on every session,
and the permanent link between your customer.id and the payer's Coinland account.
| Code | HTTP | Meaning | What to do |
|---|---|---|---|
PAY_CUSTOMER_REQUIRED | 422 | POST /sessions was called without the customer block | Send customer.id, customer.name and customer.email on every create call. The block is mandatory for every merchant, with no opt-out |
PAY_BINDING_REQUIRED | 403 | The payer tried to confirm the payment before the binding ceremony bound them | Widget-side; the payer completes the ceremony first. Nothing to fix in your code |
PAY_BINDING_REVIEW | 403 | The binding is awaiting your review, so the payment is refused | Approve or reject it in the console's Customers tab or via POST /customers/{id}/binding/review |
PAY_BINDING_MISMATCH | 403 | The signed-in Coinland account is not the one bound to this customer.id | The customer signs in with the bound account -- or, if the link is genuinely wrong, you revoke the binding |
PAY_BINDING_CONFLICT | 409 | The customer.id or the Coinland account already holds a live binding | Read the binding and decide; revoke first if the existing link is the wrong one |
PAY_BINDING_STATE | 409 | The action is illegal in the binding's current state, for example reviewing an active binding | Read the binding and branch on status |
The payment confirm can also answer TOTP_REQUIRED, TOTP_NOT_ENROLLED or TOTP_INVALID -- the
two-factor step-up every payment carries, at the same security tier as a withdrawal. Those are
surfaced to the payer inside the widget and never reach your integration.
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.
MERCHANT_DISABLED is not a code to retry through
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
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
| 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 |
A timeout is not a failure
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
Keep the map in one place, keyed on the code, with a fallback:
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.