# X-Trader API error codes explained

URL: https://docs.blizz.id/xtrader/errors/
Updated: 2026-10-16

Every X-Trader Public API error returns a JSON body with a stable machine-readable `error.code` (17 possible values), a human-readable `error.message`, optional field-level `error.fields[]`, and a `request_id`. Clients should branch on `error.code`, honour `Retry-After` on `429` and `503`, and keep `X-Request-Id` or `request_id` for support reports. The spec declares the statuses `400`, `401`, `403`, `404`, `409`, `429`, `500` and `503` for every operation.

Primary source: [X-Trader API reference](https://x-trader.ai/api/docs). This page is an independent explanation of X-Trader Public API v1.0.0; it is not affiliated with X-Trader.

## Error body

```json
{
  "error": {
    "code": "validation_error",
    "message": "Request validation failed",
    "fields": [
      { "path": "config.buy_amount", "reason": "invalid_string" }
    ]
  },
  "request_id": "req_0123456789abcdef"
}
```

The values above are illustrative; the spec defines the shape, not concrete messages or reasons.

| Field | Type | Required | Meaning |
|---|---|---|---|
| `error.code` | enum string | yes | Stable code; branch on this |
| `error.message` | string | yes | English summary for humans; do not parse |
| `error.fields` | array, max 20 | no | Field-level validation failures |
| `error.fields[].path` | string | yes (per item) | Dot-separated JSON path; `$` is the whole request body |
| `error.fields[].reason` | string | yes (per item) | Stable reason produced by the request schema |
| `request_id` | string | yes | Request identifier for support |

The error object is strict and never contains database or internal implementation details.

## Headers on errors

| Header | When | Use |
|---|---|---|
| `Retry-After` | `429` and `503` responses | Minimum wait before retrying |
| `X-Request-Id` | Every response (or `request_id` in the body) | Quote it when reporting a problem |

## Status mapping

The spec uses one shared `ErrorResponse` schema for all error statuses and does not publish a per-code status table or response examples. The status column below is therefore documented only where the spec ties a code to a status (`404 not_found`, and the `Retry-After` statuses `429` and `503`). The remaining assignments are inferred from the declared status set and standard HTTP semantics, and are marked "inferred". Handle errors by `error.code`, not by status.

## All error codes

| `error.code` | HTTP status | What triggers it | What to do |
|---|---|---|---|
| `invalid_request` | 400 (inferred) | Malformed request that is not a schema field error, for example unparseable JSON, a body over 64 KiB, an invalid query or header value. The spec does not enumerate triggers. | Fix the request; do not retry unchanged. |
| `invalid_api_token` | 401 (inferred) | The bearer token is missing, malformed or not accepted. A token replaced by rotation or revoked is presumably reported this way; the spec does not say so explicitly. | Check the `Authorization: Bearer xt_live_<id>.<secret>` header; rotate or create a token if needed. |
| `insufficient_scope` | 403 (inferred) | The token lacks the operation's required scope, for example `sniper:delete` for `DELETE /sniper/configs/{id}`. | Call `GET /me` to see `scopes`; issue a token with the needed scope. |
| `ip_not_allowed` | 403 (inferred) | The request comes from an address outside the token's IP allowlist. | Call from an allowlisted address or update the allowlist in the web app. |
| `rate_limit_exceeded` | 429 (inferred from `Retry-After`) | Too many requests. The spec does not publish the limits. | Wait for `Retry-After`, then retry with backoff. |
| `validation_error` | 400 (inferred) | Body fails the strict schema: unknown property, wrong type, JSON number instead of decimal string, more than 12 fractional digits, value out of range, forbidden field such as `user_id`, `version`, `template` or (on templates) `risk_label_id`, empty `PATCH`. A failed revalidation on resume is probably reported the same way; the spec does not name that code. | Read `error.fields[]` for `path` and `reason`, fix the body. |
| `not_found` | 404 | Resource does not exist **or** belongs to another account; also a foreign or missing `risk_label_id` in a body or `label_id` filter. | Verify the id, the product path and that the token belongs to the owning account. |
| `target_already_exists` | 409 (inferred) | The spec does not describe the trigger. The name suggests a config for the same `target_wallet` already exists in that product for this account. | List configs with `q=<wallet>` and update the existing one instead of creating another. |
| `template_name_exists` | 409 (inferred) | The spec does not describe the trigger. The name suggests a template name collision when creating or renaming a template. | Choose a different `name`. |
| `label_name_exists` | 409 (inferred) | Creating or renaming a label to a name already used in the account (names are unique per account). | Choose a different `name` or reuse the existing label. |
| `quota_exceeded` | 409 (inferred) | A limit is reached: the shared config quota (`quota.max` in `GET /me`), the 1,000-template limit, or the 21st label. | Delete unused resources, or check `GET /me` before creating. |
| `config_must_be_paused` | 409 (inferred) | Changing `config` or `linked_wallets`, or deleting, while the config is active. | `POST /{id}/pause`, perform the change, then resume. |
| `version_conflict` | 409 (inferred) | `If-Match` does not match the current `version`. | GET the resource, re-evaluate, resend with the new `ETag`. |
| `idempotency_required` | 400 (inferred) | A create request without an `Idempotency-Key` header. | Add a key of 8-128 characters from `A-Za-z0-9._:-`. |
| `idempotency_conflict` | 409 (inferred) | The same `Idempotency-Key` was reused within 24 hours with a different body. | Resend the original body, or use a new key for a new resource. |
| `service_unavailable` | 503 (inferred from `Retry-After`) | The service is temporarily unable to handle the request. | Wait for `Retry-After`; retry only safe operations. |
| `internal_error` | 500 (inferred) | Unexpected server-side failure. | Retry cautiously if the operation is safe to repeat; report with `request_id`. |

The `400`/`409` split for the last group is the least certain part of the table: the spec lists `409` for every operation, which fits the conflict-type codes, but it does not confirm any individual assignment.

## Retry policy

| Situation | Retry? |
|---|---|
| `rate_limit_exceeded`, `service_unavailable` | Yes, after `Retry-After`, with backoff |
| `internal_error` | Only if repeating is safe |
| Create with the same `Idempotency-Key` and body | Safe to retry within 24 hours |
| `PATCH`, pause, resume, delete | Safe to resend with the same `If-Match`: if the first attempt succeeded, the retry fails with `version_conflict` instead of applying twice |
| `validation_error`, `invalid_request`, `not_found`, `insufficient_scope`, `ip_not_allowed`, `*_exists`, `quota_exceeded` | No, fix the cause first |

Practical guidance: a `version_conflict` on a retried write may mean your own previous attempt succeeded. GET the resource to check before doing anything else.

## Example: handling errors

```bash
curl --include --request DELETE "https://x-trader.ai/api/v1/sniper/configs/$CONFIG_ID" \
  --header "Authorization: Bearer $X_TRADER_API_TOKEN" \
  --header 'If-Match: "v4"'
```

```python
import os
import time
import requests

BASE = "https://x-trader.ai/api/v1"
H = {"Authorization": f"Bearer {os.environ['X_TRADER_API_TOKEN']}"}

def call(method, path, retries=3, headers=None, **kw):
    hdrs = {**H, **(headers or {})}
    for attempt in range(retries + 1):
        r = requests.request(method, f"{BASE}{path}", headers=hdrs, timeout=15, **kw)
        if r.ok:
            return r
        err = r.json().get("error", {})
        code = err.get("code")
        if code in ("rate_limit_exceeded", "service_unavailable") and attempt < retries:
            time.sleep(float(r.headers.get("Retry-After", 2 ** attempt)))
            continue
        rid = r.headers.get("X-Request-Id") or r.json().get("request_id")
        for f in err.get("fields", []):
            print("field", f["path"], f["reason"])
        raise RuntimeError(f"{r.status_code} {code}: {err.get('message')} (request {rid})")

me = call("GET", "/me").json()["data"]
```

## FAQ

### Why do I get 404 not_found for my own config id?

Check that the token belongs to the same X-Trader account that owns the config and that the id is sent to the right product path (a copy-trade id under `/sniper/configs/` is not found). Ids owned by another account, and foreign label ids in `risk_label_id` or `label_id`, return the same `404 not_found` as missing ids, so the API never reveals whether they exist.

### What does version_conflict after PATCH mean?

The `If-Match` value is older than the stored `version` because the resource changed since you read it, possibly by your own earlier retry or by the web UI. GET it again, review the current state, then resend with the new `ETag` if the change still applies.

### What is config_must_be_paused?

The request needs a paused config: changing `config` or `linked_wallets`, or deleting. Pause it with `POST /{id}/pause`, make the change, then resume if needed. `remark` and `risk_label_id` can be changed while active.

### How do I handle rate_limit_exceeded?

Wait for the number of seconds in the `Retry-After` header before retrying, and add backoff. Retry writes only when they are safe to repeat (same `Idempotency-Key`, or the same `If-Match`).

### Should I parse the error message text?

No. Branch on `error.code`, which is stable. The `message` is a human-readable English summary and may change.
