docs
IntegrateErrors

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"
}
FieldTypeMeaning
error.typestringOne of three categories below. Branch on this first.
error.codestring | nullThe specific error identifier. Stable across messages and translations. Use this, not the message, for control flow.
error.messagestringHuman-readable English. For your logs and your team's UI. Don't surface verbatim to your customers.
error.paramstring | nullThe request body field that failed (e.g. lineItems[0].price, customerDetails.email). Present on validation errors.
error.doc_urlstring | nullDeep link to this code's row in the API error codes reference.
request_idstringThe 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

TypeHTTP statusWhat it meansTypical handler
invalid_request_error400, 404, 409The 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_error401The 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_error500An 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 retryable

Narrowing 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 fresh Idempotency-Key if you're using one.
  • Don't retry authentication_error either. A bad key is a bad key. Retrying ten times just produces ten log lines saying so.
  • Don't show error.message to 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 off error.code.
  • Don't ignore error.code and parse error.message instead. The message text changes; the code is stable. Branching on the message is brittle.
  • Don't silently swallow errors. Always log request_id next to every caught error. Without it, debugging in Workbench → Logs becomes scrolling.
  • Don't assume the response is XPay-shaped. A response without error.type is 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

On this page