Error Response Format
The API returns three distinct error envelopes depending on where the request failed. A client that handles all three is a client that never has to guess.Application errors
Business and application errors (invalid parameters, missing resources, conflicts, rate limits, server errors) return a structured envelope:error_code(string): Stable, machine-readable error code (e.g.PAIR_NOT_FOUND,VALIDATION_ERROR,NOT_FOUND,RATE_LIMIT_EXCEEDED,INTERNAL_ERROR).message(string): Human-readable description of the error.details(object): Optional structured context about the error (may be empty).request_id(string): Request correlation identifier — include it when contacting support.
error_code, never on message. The codes are stable; the wording is not.
Request-validation errors (HTTP 422)
Requests that fail schema validation before reaching application logic return FastAPI’s validation envelope with HTTP422:
detail(array<object>): One entry per validation failure.loc(array<(string | integer)>): Path to the offending field.msg(string): Validation error message.type(string): FastAPI/Pydantic validation error type.
Legacy errors
A few endpoints still return some400 responses as a bare detail object, without error_code or request_id — for example an unsupported route on POST /market/quote or a malformed pair_ticker filter on GET /market/pairs:
detail(string): Human-readable description of the error.
400 responses should accept both the structured envelope and this legacy shape.
HTTP Status Codes
4xx class means the request needs changing; the 5xx class means the request was fine and something upstream was not. Only the 5xx class plus 429 are worth retrying — see Retry and Resilience.
Requests and Authentication
400 Bad Request
400 Bad Request
400, with either an error_code envelope or a bare detail stringCause: missing or invalid parameters in the request.Solutions:- Verify the payload and confirm every required field is present with valid data
- Make your handler accept both
400shapes — readerror_codeif present, fall back todetail - For
{"detail": "Route not supported for this pair..."}, check the pair’s actualrouteswithPOST /api/v1/market/pairs/routesand send afrom_asset.layer→to_asset.layercombination that appears there - For pair filters,
pair_tickermust beBASE/QUOTE(e.g.BTC/USDT), and single-pair identifiers are mutually exclusive
403 Forbidden
403 Forbidden
403 on an endpoint that accepts your key elsewhereCause: the key is valid but not allowed from this origin.Solution: call from an allowed origin, or request that the origin be added. This is the failure mode you hit when a key intended for a backend is used from a browser or a new deployment host — see also the CORS note in the FAQ.422 Unprocessable Entity
422 Unprocessable Entity
422 with a detail array rather than an error_codeCause: the request failed schema validation before reaching application logic, so no business error was produced.Solution: read detail[].loc — it is the exact path to the offending field. ["body", "rfq_id"] with "type": "missing" means you omitted rfq_id entirely; a "type" of int_parsing usually means an amount was sent as a string. Do not treat 422 as retryable: the same payload will always fail.404 on an asset, pair, or endpoint you expect to exist
404 on an asset, pair, or endpoint you expect to exist
PAIR_NOT_FOUND, NOT_FOUND, or an empty assets array for a ticker you know is supportedCauses:- The endpoint URL or a resource identifier is wrong
- The asset or pair is inactive, and
GET /market/pairsfilters to active pairs by default (active_only=true) - You filtered by
asset_idwhere the maker expects the RGB contract ID exactly, including thergb:prefix - You are on the wrong environment — asset IDs are not portable between Signet and mainnet
- Check the endpoint path and resource identifiers, including the
/api/v1prefix - Re-query with
active_only=falseto see whether the pair exists but is disabled - Discover IDs from
GET /api/v1/market/assetsrather than hardcoding them, and key your config by environment - Check
totalin the response — it is the count of all matches, so atotalabove your page size means you are looking at pagination, not absence
429 Too Many Requests
429 Too Many Requests
RATE_LIMIT_EXCEEDED, typically while polling for pricesCause: too many requests in a short period.Solutions:- Read
X-RateLimit-RemainingandX-RateLimit-Resetand back off before you are cut off, rather than reacting to the429 - Retry after the indicated wait time, if one is provided
- Stop polling
POST /market/quotein a loop — open the WebSocket and send aquote_requestwhen you actually need a new price - Cache
market/assetsandmarket/pairs; both are safe to cache and change rarely - Remember the limits are per IP and per endpoint and global, so a noisy neighbour behind the same NAT can consume your budget
500, 502, or 503
500, 502, or 503
INTERNAL_ERROR, or 503 on endpoints that were working a minute agoCauses:500— an unexpected error on the server502— an invalid response from an upstream server503— the maker or the LSP node is temporarily unreachable, under maintenance, or overloaded
- Retry with exponential backoff and jitter; these are the transient class of failures
- Do not blind-retry a
/swaps/executecall on a timeout — pollPOST /api/v1/swaps/atomic/statusfirst to see whether it actually landed - If it persists, report it with the
request_idfrom the error body
Quotes and Amounts
Quote expired before I could init the swap
Quote expired before I could init the swap
/swaps/init rejects an rfq_id that was valid moments agoCause: the quote’s expires_at has passed. The window is tens of seconds, not minutes.Solutions:- Quote as late as possible — immediately before init, never before a confirmation screen the user might sit on
- Keep the whole init → whitelist → execute sequence tight; it all runs against one quote
- If a user hesitates, discard the
rfq_idand request a fresh quote when they resume
Amount rejected as invalid
Amount rejected as invalid
VALIDATION_ERROR or a 400 mentioning the amountCauses:- Below
min_amountor abovemax_amountfor that layer — the limits live per-layer in each asset’sendpointsarray, not on the asset itself - Display units sent instead of raw units
- An amount on both legs, or on neither
- Read the limits from the matching
endpointsentry for the layer you are routing over - Send BTC legs in millisatoshis and RGB legs in raw units per the asset’s
precision— see the FAQ - Set exactly one of
from_asset.amount(forward quote) orto_asset.amount(reverse quote)
Amounts or prices are off by orders of magnitude
Amounts or prices are off by orders of magnitude
- A BTC leg treated as satoshis when the API means millisatoshis
- One asset’s precision applied to another — precision is per asset, and BTC and USDT do not share one
priceread as a display rate; it is one wholefrom_assetunit expressed in the smallest unit ofto_asset
- Display
to_asset.amountfrom the response — the fee is already folded into it — instead of recomputing fromprice - Pull
precisionper asset fromGET /api/v1/market/assetsand never hardcode a divisor - Sanity-check a small quote round-trip on Signet before wiring the conversion into a UI
Swaps
/swaps/execute fails after a successful init
/swaps/execute fails after a successful init
init returned a swapstring, but execute failsCause: the swapstring was never whitelisted on your own node, so the taker side cannot honour the HTLC. This is the single most common integration failure, because the middle step is not a Maker API call.Solution: the three steps must run in order, against two different servers:POST /api/v1/swaps/initon the Maker API → returnsswapstring,payment_hash,access_token- Whitelist the
swapstringvia the/takerAPI of your own RGB Lightning Node POST /api/v1/swaps/executeon the Maker API withswapstring,taker_pubkey, andpayment_hash
taker_pubkey is your node’s pubkey and that the payment_hash matches the one from init. See Swap Protocol.404 Swap not found when polling status
404 Swap not found when polling status
POST /api/v1/swaps/atomic/status returns 404 for a swap you know existsCause: a missing or invalid access_token. The 404 is deliberately uniform — it does not distinguish “wrong token” from “no such swap”, so the endpoint cannot be used to probe payment hashes.Solutions:- Send both
payment_hashand theaccess_tokenreturned by/swaps/init - If the token was never persisted, it cannot be recovered — it is returned only once. Store it with the payment hash at init time
- Check you are polling the environment the swap was created on
Swap stuck on Pending
Swap stuck on Pending
execute returned 200, but the status stays Pending and the assets have not arrivedCauses:- The HTLC is still in flight —
executereturning is not settlement - No route with enough capacity on the asset leg
- Pending RGB transfers on your node have not been advanced
- Keep polling
/swaps/atomic/status; the terminal states areSucceeded,Expired, andFailed - On your node, refresh transfers and check outbound capacity on the specific asset — not just the total balance
- Compare
expires_aton the swap object against the current time to know how long the HTLC still has
Swap ended as Expired or Failed
Swap ended as Expired or Failed
Expired or FailedCauses:Expired— the HTLC timeout elapsed before both sides completed, usually because whitelisting or execute came too lateFailed— the HTLC could not be routed or settled, typically a capacity or liquidity problem
- Your funds are not at risk: an incomplete atomic swap returns both sides’ funds automatically. Do not re-send funds manually
- Retry from a fresh quote — the old
rfq_idandswapstringare spent - If it fails repeatedly on the same pair, check capacity on that asset’s channel and order more liquidity via LSPS1
WebSocket
WebSocket errors are not HTTP responses — they arrive as JSON messages on the open socket, and on a critical error the server may close the connection outright.Connected, but no quote_response arrives
Connected, but no quote_response arrives
quote_request was sent, but no quote_response comes backCause: the request failed. Failures come back as a message with an error field instead of a quote_response, so a client that only listens for quote_response sees silence.Solutions:- Log every inbound frame, not just the ones you expect, and handle the
errorshape - Check the usual culprits: unknown asset, a route the pair does not support, an amount outside the per-layer limits
- Confirm you set exactly one of
from_amount/to_amount
The price stops updating
The price stops updating
quote_request whenever you need a current price, and drive it from your own interval or from user action.Connection drops repeatedly
Connection drops repeatedly
- Wait a few seconds (5–10 s)
- Re-establish the connection
- Re-issue any pending
quote_requestmessages to obtain fresh quotes — quotes do not survive the reconnect
- Send periodic
pingmessages and expect apong; an idle connection through a proxy is a connection about to be closed - Use a fresh unique
{client_id}per session, and verify the URL iswss://, notws:// - If you are behind a corporate proxy that strips WebSocket upgrades, fall back to
POST /market/quote
Channel Orders (LSPS1)
estimate_fees or create_order rejects a missing rfq_id
estimate_fees or create_order rejects a missing rfq_id
client_asset_amountCause: when client_asset_amount > 0 the client is purchasing assets, so a fresh rfq_id from POST /api/v1/market/quote is required to price them.Solutions:- Quote first, then pass that
rfq_idintoestimate_fees/create_order - Requote if the quote expired between the two calls
- Omit
client_asset_amountentirely if you only want LSP-side liquidity — no quote is needed then
Order rejected on channel size or asset amount
Order rejected on channel size or asset amount
create_order fails validation on balances or asset amountsCause: the request falls outside the LSP’s advertised limits.Solutions:- Read
optionsfromGET /api/v1/lsps1/get_infoand stay insidemin_channel_balance_sat/max_channel_balance_sat, the initial-balance bounds, andmax_channel_expiry_blocks - Check the per-asset caps in the same response —
min_initial_lsp_amount/max_initial_lsp_amountare per asset and can be0, which means that side is not available for it - Connect to the peer at
lsp_connection_urlbefore ordering, so the channel has somewhere to open
Order sits in PENDING_RATE_DECISION
Order sits in PENDING_RATE_DECISION
order_state is PENDING_RATE_DECISIONCause: the market rate moved significantly before the payment settled, so the LSP paused the order instead of opening a channel at a stale price. It is waiting on you.Solution: call POST /api/v1/lsps1/rate_decision with the order_id, the order’s access_token, and accept_new_rate — true to proceed at the current rate, false to trigger a refund to refund_onchain_address. Refunds return a refund_txid. Calling this endpoint on an order in any other state returns 400.get_order returns 400 or 404
get_order returns 400 or 404
access_token returns 400, an unknown order_id returns 404.Solution: store the access_token from create_order alongside the order_id; it is returned only at creation and is required by both get_order and rate_decision. Set email on the order if you want notifications as a backstop.Order paid but the channel is not usable yet
Order paid but the channel is not usable yet
order_state is CHANNEL_OPENINGSolutions:- Keep polling
get_order— it is safe to poll, andchannelstaysnulluntil the channel exists - Compare
required_channel_confirmationsagainst the current height fromGET /api/v1/lsps1/network_info - Note that payment expiries live under
payment.bolt11.expires_atandpayment.onchain.expires_at, and the channel’s own expiry underchannel.expires_at— there is no top-levelexpires_atto read
Retry and Resilience
Design the client so a bad response is a handled case rather than a crash.- Handle errors gracefully. Parse all three envelopes, branch on
error_code, and surface something actionable instead of propagating a raw failure to the user. - Retry only what is retryable. Transient failures —
500,502,503, and429— are worth another attempt with exponential backoff and jitter. Client errors (400,401,403,404,422) will fail identically no matter how many times you send them. - Respect the rate limits. Read the
X-RateLimit-*headers and stay under the ceiling rather than discovering it. Cache static data such as assets and pairs. - Treat swap execution as non-idempotent. A timeout on
/swaps/executeis not proof of failure — poll/swaps/atomic/statusbefore re-sending anything. - Cap total attempts. An unbounded retry loop against a
503becomes your own denial of service.
Before You Report
A reproducible report is usually diagnosed in one round trip.- Check your logs for the full error response, not just the status code — application errors carry a
request_idthat correlates your request with our logs. - Validate the inputs: confirm every parameter and payload field meets the endpoint’s requirements, and that amounts are in raw units.
- Reproduce with
curl, with any API key redacted. - Confirm the base URL includes
/api/v1and points at the environment you think it does. - Try the interactive playground on the endpoint pages, which runs against Signet — if the same call succeeds there, the difference is in your client.
Get Help
Check the FAQ for questions rather than errors, and Additional Resources for specifications and upstream links. For anything else, report a problem through your preferred channel from the options below, including:- Request URL and HTTP method
- Request payload (redact any API key)
- Full error response, including
request_id - Timestamp of the request
- Environment (Signet / Mainnet)