# API errors (/en/integrate/errors/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](/integrate/errors/payment-errors).

## The envelope [#the-envelope]

Every API error response has the same shape:

```json
{
  "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](/integrate/errors/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](/integrate/workbench/logs-panel). |

`code`, `param`, and `doc_url` can be absent. The other three are always present.

<Callout type="info">
  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.
</Callout>

## The three types [#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.                                                                         |

When you retry a write (a `POST` or `PATCH`), send an `Idempotency-Key` so the retry returns the original result instead of repeating the operation. See [Idempotency](/integrate/idempotency).

## Handling pattern [#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.

<Tabs items="[&#x22;Node.js&#x22;, &#x22;Python&#x22;]">
  <Tab value="Node.js">
    ```typescript
    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);
      }
    }
    ```
  </Tab>

  <Tab value="Python">
    ```python
    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
    ```
  </Tab>
</Tabs>

## Narrowing on `code` [#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`.

```typescript
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](/integrate/errors/api-error-codes).

## What not to do [#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](/integrate/workbench/logs-panel) 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 [#where-to-next]

<Cards>
  <Card icon="<Hash />" title="API error codes" href="/integrate/errors/api-error-codes">
    The full list of `error.code` values, grouped by domain, with one-line descriptions.
  </Card>

  <Card icon="<CreditCard />" title="Payment errors" href="/integrate/errors/payment-errors">
    The other surface: how to handle a customer's failed card payment via `lastPaymentError`.
  </Card>

  <Card icon="<Terminal />" title="Logs panel" href="/integrate/workbench/logs-panel">
    Look up any failing call by `request_id` and see the full request, response, and error fields.
  </Card>

  <Card icon="<Layers />" title="Errors introduction" href="/integrate/errors/introduction">
    Back to the top of the Errors group. The two-surfaces map.
  </Card>
</Cards>