API errors
The error envelope returned by every failed API call. Three error types, the fields they carry, and the handler pattern.
This page covers the synchronous errors XPay returns: when your server makes an API call and gets a non-2xx response, what's in the body, and how to handle it.
For asynchronous payment failures (a card decline that lands on a Payment Intent's lastPaymentError), that's a different surface entirely. See Payment errors.
The envelope
Every API error response has the same shape:
{
"error": {
"type": "invalid_request_error",
"code": "parameter_missing",
"message": "Missing required parameter: lineItems",
"param": "lineItems",
"doc_url": "https://docs.xpay.app/integrate/errors/api-error-codes#parameter_missing"
},
"request_id": "req_3STkwmFGhGHoO0IX13BRo5iU"
}| Field | Type | Meaning |
|---|---|---|
error.type | string | One of three categories below. Branch on this first. |
error.code | string | null | The specific error identifier. Stable across messages and translations. Use this, not the message, for control flow. |
error.message | string | Human-readable English. For your logs and your team's UI. Don't surface verbatim to your customers. |
error.param | string | null | The request body field that failed (e.g. lineItems[0].price, customerDetails.email). Present on validation errors. |
error.doc_url | string | null | Deep link to this code's row in the API error codes reference. |
request_id | string | The req_* ID for the failing request. Always log this. Use it to find the request in Workbench → Logs. |
code, param, and doc_url can be absent. The other three are always present.
If you can only afford one branch in your handler, branch on type, not on code or HTTP status. The type list is stable across releases; the code list grows over time as new error reasons are added.
The three types
| Type | HTTP status | What it means | Typical handler |
|---|---|---|---|
invalid_request_error | 400, 404, 409 | The request was rejected because of something on your side: missing or malformed field, resource not found, conflicting state. | Don't retry. Fix the request. Show the message to whoever caused it (your team, your form). |
authentication_error | 401 | The API key or HMAC signature is missing, invalid, inactive, or wrong-format. | Don't retry. Check XPAY_SECRET_KEY in your env, the active state of the key in the dashboard, and that you're sending Authorization: Bearer <key>. |
api_error | 500 | An unexpected server-side problem on XPay's side. | Retry with exponential backoff. If it persists, quote request_id to support. |
Handling pattern
The handler is a single try / catch. Inside the catch, parse the response, branch on error.type, narrow on error.code for the small number of codes you care about, log everything, surface what's appropriate.
async function callXPay<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`https://api.xpay.app${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.XPAY_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (res.ok) return res.json() as Promise<T>;
// Read the canonical envelope. Every XPay error response has `error.type`.
const payload = await res.json().catch(() => null);
const xpayError = payload?.error;
const requestId = payload?.request_id;
if (!xpayError?.type) {
// Transport-level failure: not XPay-shaped. Treat as a client-side bug.
throw new Error(`Non-XPay response: ${res.status}`);
}
// Always log first, branch second.
log.error("xpay api error", {
requestId,
type: xpayError.type,
code: xpayError.code,
param: xpayError.param,
message: xpayError.message,
docUrl: xpayError.doc_url,
});
switch (xpayError.type) {
case "invalid_request_error":
// Your request is wrong. Don't retry. Bubble up to whoever sent it.
throw new BadRequest(xpayError);
case "authentication_error":
// Your secret key is wrong. Page yourself; don't retry.
throw new ConfigError(xpayError);
case "api_error":
// XPay is having a moment. Retry with backoff.
throw new RetryableError(xpayError);
default:
// Unknown type. Treat as retryable to be safe.
throw new RetryableError(xpayError);
}
}import os, requests
def call_xpay(path: str, body: dict) -> dict:
res = requests.post(
f"https://api.xpay.app{path}",
headers={
"Authorization": f"Bearer {os.environ['XPAY_SECRET_KEY']}",
"Content-Type": "application/json",
},
json=body,
timeout=30,
)
if res.ok:
return res.json()
payload = res.json() if res.content else {}
xpay_error = payload.get("error") or {}
request_id = payload.get("request_id")
if not xpay_error.get("type"):
# Transport-level failure: not XPay-shaped.
raise RuntimeError(f"Non-XPay response: {res.status_code}")
log.error(
"xpay api error",
extra={
"request_id": request_id,
"type": xpay_error.get("type"),
"code": xpay_error.get("code"),
"param": xpay_error.get("param"),
"message": xpay_error.get("message"),
"doc_url": xpay_error.get("doc_url"),
},
)
error_type = xpay_error["type"]
if error_type == "invalid_request_error":
raise BadRequest(xpay_error)
if error_type == "authentication_error":
raise ConfigError(xpay_error)
if error_type == "api_error":
raise RetryableError(xpay_error)
raise RetryableError(xpay_error) # unknown type, treat as retryableNarrowing on code
type decides the broad branch. code decides the specific message you show. A handful of codes are worth special-casing in your UI; the rest can use error.message plus a "Learn more" link to error.doc_url.
if (xpayError.type === "invalid_request_error") {
switch (xpayError.code) {
case "resource_missing":
// The ID you sent doesn't exist. Likely your code, not the user's.
return show("That record was deleted or never existed.");
case "checkout_session_expired":
// The session is past expiresAt. Create a new one.
return await createNewSession();
case "price_sold_out":
// Stock ran out between session creation and payment.
return show("Sorry, that item is sold out.");
case "merchant_not_activated":
// Your account isn't approved for live mode yet.
return show("Your account needs activation. Contact support.");
default:
// Generic invalid_request_error. Surface the message.
return show(xpayError.message);
}
}For the full list of codes you might see, see API error codes.
What not to do
- Don't retry
invalid_request_error. The same request will fail the same way. Fix the input and try again, with a freshIdempotency-Keyif you're using one. - Don't retry
authentication_erroreither. A bad key is a bad key. Retrying ten times just produces ten log lines saying so. - Don't show
error.messageto your end customers verbatim. It's English, technical, and may contain internal IDs. Use it for your logs and your support team's UI. Build customer-facing copy offerror.code. - Don't ignore
error.codeand parseerror.messageinstead. The message text changes; the code is stable. Branching on the message is brittle. - Don't silently swallow errors. Always log
request_idnext to every caught error. Without it, debugging in Workbench → Logs becomes scrolling. - Don't assume the response is XPay-shaped. A response without
error.typeis a transport-level failure (broken proxy, wrong content type, oversized payload). Treat it as a client-side bug to fix, not as a runtime branch.
Where to next
API error codes
The full list of error.code values, grouped by domain, with one-line descriptions.
Payment errors
The other surface: how to handle a customer's failed card payment via lastPaymentError.
Logs panel
Look up any failing call by request_id and see the full request, response, and error fields.
Errors introduction
Back to the top of the Errors group. The two-surfaces map.
Introduction
XPay surfaces failures in two distinct places: the response body of an API call you made, and the lastPaymentError field on a payment that failed. Pick the surface that matches your problem.
API error codes
Every code returned in `error.code` on a failed API call, grouped by domain. Each entry's URL is the value of `error.doc_url`.