Coinland PayDocs

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

CodeHTTPMeaningWhat to do
PAY_SESSION_NOT_FOUND404No session or payment with that id under your accountCheck the id. A session belonging to another business answers the same way, on purpose
PAY_SESSION_EXPIRED422The session's TTL passed before the customer confirmedCreate a new session; nothing was reserved
PAY_SESSION_STATE409The action is illegal in the session's current status, for example cancelling a completed oneRead the session and branch on status
PAY_DUPLICATE_REFERENCE409The same reference_id was sent with a different payloadDo not mint a new id. Either resend the original payload, or treat this as a new order
PAY_CURRENCY_NOT_ACCEPTED422A coin in amounts is not in your accepted set, is disabled platform-wide, or is Toman -- this rail is crypto onlyRead accepted_currencies from GET /me at startup instead of hard-coding coins
PAY_AMOUNT_INVALID422Non-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 neitherFormat amounts as decimal strings at the coin's own precision, and send exactly one of amounts or price_usd
PAY_RATE_UNAVAILABLE503price_usd was sent but no accepted coin could be priced right nowRetry rather than change the request; the request itself is fine
PAY_SELF_PAYMENT422The account trying to pay is the business that created the sessionNothing to fix in your code; a business cannot pay itself
PAY_RECEIPT_INVALID422Receipt verification failed: bad signature, altered payload, or unknown paymentTreat 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.

CodeHTTPMeaningWhat to do
MERCHANT_PAYOUTS_DISABLED403Payouts are not armed for your business, or the email-lookup path is not enabled for youContact 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_KIND403A checkout key was used on a payout route, or a payout key on a checkout routeUse the key of the other class. The key itself is valid, which is why this is not UNAUTHORIZED
PAY_RECIPIENT_INVALID422The recipient cannot be paid, or the request sent both recipient paths, or neitherCheck 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_LIMIT422Over the per-payout maximum or the trailing-24-hour ceilingSplit the payout across days, wait out the window, or ask Coinland to raise the limit
PAY_REFUND_EXCEEDS_PAYMENT422Cumulative refunds would exceed the payment's charged_amountSum your own refunds of that payment against its charged_amount and refund only what is left
PAY_LOOKUP_THROTTLED429Your recipient-lookup budget for the minute or the day is spentBack 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.

CodeHTTPMeaningWhat to do
PAY_CUSTOMER_REQUIRED422POST /sessions was called without the customer blockSend customer.id, customer.name and customer.email on every create call. The block is mandatory for every merchant, with no opt-out
PAY_BINDING_REQUIRED403The payer tried to confirm the payment before the binding ceremony bound themWidget-side; the payer completes the ceremony first. Nothing to fix in your code
PAY_BINDING_REVIEW403The binding is awaiting your review, so the payment is refusedApprove or reject it in the console's Customers tab or via POST /customers/{id}/binding/review
PAY_BINDING_MISMATCH403The signed-in Coinland account is not the one bound to this customer.idThe customer signs in with the bound account -- or, if the link is genuinely wrong, you revoke the binding
PAY_BINDING_CONFLICT409The customer.id or the Coinland account already holds a live bindingRead the binding and decide; revoke first if the existing link is the wrong one
PAY_BINDING_STATE409The action is illegal in the binding's current state, for example reviewing an active bindingRead 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

CodeHTTPMeaningWhat to do
MERCHANT_NOT_FOUND404The account behind this key is not a businessAsk Coinland support to promote the account
MERCHANT_DISABLED403This business cannot take payments right nowContact Coinland. One code covers a suspended business and a globally disabled rail; the distinction is operator-only because the remedy is identical
MERCHANT_KEY_LIMIT422The active API-key ceiling is reachedRevoke a key in the business console before minting another
MERCHANT_WEBHOOK_URL_INVALID422Not https, unparsable, or pointed at a private addressUse a publicly reachable https URL
MERCHANT_LOGO_INVALID422The uploaded logo is outside the type or size allowlistCheck the limits shown in the console
MERCHANT_CONVERT_TARGET_INVALID422The auto-convert target is not a coin Coinland currently allows as a conversion destinationPick one of the targets the console offers
MERCHANT_EXISTS409Promotion was attempted on an account that is already a businessNothing 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.

CodeHTTPMeaning
UNAUTHORIZED401Missing, malformed, revoked or unknown API key. One code for every auth-layer refusal, so a caller cannot probe which part was wrong
FORBIDDEN403Authenticated, but not permitted
VALIDATION_FAILED422The request body failed schema validation: a missing required field, a wrong type, a string over its maximum
RATE_LIMITED429Too many requests. Back off with jitter before retrying
NOT_FOUND404No such route
CONFLICT409A generic state conflict, where no more specific code applies
INTERNAL_SERVER_ERROR500Something failed on our side
SERVICE_UNAVAILABLE503Temporarily unable to serve. Retry with backoff
MAINTENANCE_MODE503Coinland is in maintenance. Reads and writes are both off

Which errors to retry

SituationRetry?
A timeout or a dropped connectionYes, with the same reference_id. That is what idempotency is for
429 RATE_LIMITEDYes, after a backoff with jitter
500, 502, 503Yes, with exponential backoff
Any other 4xxNo. 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.

On this page