# X-Trader API write rules: Idempotency-Key, If-Match, ETag, decimal strings

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

Every write to the X-Trader Public API must use a JSON body of at most 64 KiB, strict objects with no unknown properties, and decimal strings (not JSON numbers) for amounts, fees, percentages and durations. Create requests require an `Idempotency-Key` header; update, pause, resume and delete requests on configs and templates require `If-Match` with the latest `ETag` such as `"v3"`. New configurations are always created paused, and committed changes reach execution nodes asynchronously.

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.

## Summary of write requirements

| Operation | `Idempotency-Key` | `If-Match` | Response `ETag` |
|---|---|---|---|
| `POST` create config (sniper, copy-trade, transfer-snipe) | required | no | yes (`201`) |
| `POST` create template (sniper, copy-trade) | required | no | yes (`201`) |
| `POST /labels` | required | no | no |
| `PATCH` config or template | no | required | yes (`200`) |
| `POST /{id}/pause`, `POST /{id}/resume` | no | required | yes (`200`) |
| `DELETE` config or template | no | required | no (`204`, empty) |
| `PATCH /labels/{id}`, `DELETE /labels/{id}` | no | not used | no |
| `GET` single config or template | n/a | n/a | yes |

## Body size and strict objects

- JSON bodies are limited to **64 KiB**.
- Every object in a request schema is strict (`additionalProperties: false`). An unknown property anywhere, including inside nested `config` objects, is rejected with `validation_error` instead of being ignored.
- `user_id` is never accepted. Config create and update bodies also reject `version` and `template`. Template bodies reject `user_id`, `version` and `risk_label_id`. Label bodies reject `user_id`.
- `PATCH` bodies must contain at least one top-level field.
- Text fields are trimmed: leading and trailing spaces are removed from `remark` (max 64 characters) and from `name` (labels 1-32, templates 1-64 characters).

## Decimal strings

Amounts, fees, percentages and durations are sent as **decimal strings**, for example `"0.1"`, with at most **12 fractional digits**. Do not send JSON numbers for these fields. Each field has its own range in the schema, for example:

| Field (sniper config) | Rule from the spec |
|---|---|
| `config.buy_amount` | SOL, decimal string greater than 0 and at most 1,000,000 (max length 32) |
| `config.jito_mode.tip_amount` | SOL, decimal string from 0 to 1,000 |
| `config.jito_mode.priority_fee` | SOL, decimal string from 0 to 1,000 |

```json
{ "buy_amount": "0.1", "jito_mode": { "tip_amount": "0.001", "priority_fee": "0.0005" } }
```

Integers that are not amounts (for example label `sort_order`, or `limit` in list queries) are ordinary JSON integers. Field-by-field ranges are listed on [sniper config](/xtrader/config-sniper/) and [copy-trade config](/xtrader/config-copy-trade/).

## Idempotency-Key on create

| Property | Value |
|---|---|
| Header | `Idempotency-Key` |
| Required on | every `POST` that creates a config, template or label |
| Length | 8-128 characters |
| Allowed characters | `A-Z`, `a-z`, `0-9`, `.`, `_`, `:`, `-` (pattern `^[A-Za-z0-9._:-]+$`) |
| Retention | 24 hours |
| Same key, same body | safe to retry |
| Same key, different body | `idempotency_conflict` |
| Missing header | `idempotency_required` |

Practical guidance: generate one key per logical create (for example a UUID or `create-sniper-20260720-001`), store it with the pending operation, and reuse it on network retries. Use a new key only for a genuinely new resource.

```bash
curl --request POST 'https://x-trader.ai/api/v1/sniper/configs' \
  --header "Authorization: Bearer $X_TRADER_API_TOKEN" \
  --header 'Content-Type: application/json' \
  --header 'Idempotency-Key: create-sniper-20261016-001' \
  --data @sniper-config.json
```

## If-Match and ETag (optimistic concurrency)

Configs and templates carry an integer `version`. Single-resource responses return it as the `ETag` header in the form `"v<version>"`, for example `"v3"`. Writes to an existing config or template must send that value back:

| Property | Value |
|---|---|
| Header | `If-Match` |
| Format | `"v<N>"` including the double quotes, pattern `^"v[1-9][0-9]*"$` |
| Required on | `PATCH`, `DELETE`, `POST /{id}/pause`, `POST /{id}/resume` for configs; `PATCH`, `DELETE` for templates |
| Stale value | `version_conflict` |

On `version_conflict`, GET the resource again, compare its current state with the intended change, and only then decide whether to resend with the new `ETag`. List responses do not return `ETag` headers; take the version from a single-resource `GET` or from the last write response.

Changing or clearing a label binding (`risk_label_id`) does not require pausing the task, but it still requires the latest `If-Match`.

```python
import os
import requests

BASE = "https://x-trader.ai/api/v1"
H = {"Authorization": f"Bearer {os.environ['X_TRADER_API_TOKEN']}"}
cfg_id = "00000000-0000-4000-8000-000000000000"  # your config id

r = requests.get(f"{BASE}/sniper/configs/{cfg_id}", headers=H, timeout=10)
r.raise_for_status()
etag = r.headers["ETag"]  # e.g. '"v3"'

r = requests.patch(
    f"{BASE}/sniper/configs/{cfg_id}",
    headers={**H, "If-Match": etag},
    json={"remark": "night session"},
    timeout=10,
)
if r.status_code == 409 and r.json()["error"]["code"] == "version_conflict":
    # someone else changed it: re-read before deciding to retry
    pass
r.raise_for_status()
new_etag = r.headers["ETag"]
```

## What can change while a config is active

| Change | Active config | Paused config |
|---|---|---|
| `remark` | allowed | allowed |
| `risk_label_id` (bind, change, `null` to unassign) | allowed | allowed |
| `config` (full replacement) | rejected, pause first (`config_must_be_paused`) | allowed |
| `linked_wallets` (copy-trade, full replacement) | rejected, pause first | allowed |
| `target_wallet` | immutable | immutable |
| Delete | rejected | allowed |

When `config` is present in a `PATCH`, it replaces the whole nested object and must satisfy the full schema; partial nested patches are not supported. `linked_wallets` likewise replaces the whole array.

## Configs are created paused

Create endpoints always store the resource with `is_active=false`. Inspect the `201` response, then call `POST /{id}/resume` with its `ETag` to start execution. Resume revalidates the complete stored config against the current strict schema; data that no longer passes is not activated.

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

## Persistence and propagation

A successful write returns `meta.persistence = "committed"`: the database write is complete. `meta.propagation = "eventual"` means execution nodes observe the change asynchronously. Pause changes the database state immediately, but nodes may still act on the previous state for a short time. For accounts with very large task sets the spec states refresh can take 30-60 seconds in extreme cases.

## Labels

- Sniper, copy-trade and transfer-snipe tasks share one label set per account, maximum **20** labels.
- Create labels at `/labels`, then bind them to tasks with `risk_label_id` on create or `PATCH`.
- A label id owned by another account returns `404 not_found`, the same as a missing id.
- Labels do not use `ETag` or `If-Match`. Details: [labels](/xtrader/labels/).

## Templates

- Sniper templates use `sniper:*` scopes; copy-trade templates use `copytrade:*` scopes. There are no transfer-snipe templates.
- Sniper and copy-trade templates share a limit of **1,000** templates per account.
- Templates never accept or return `risk_label_id`.
- Updating a template does not rewrite existing task snapshots. Deleting a template detaches linked tasks (`ON DELETE SET NULL`) without deleting them or changing their stored config.
- Details: [templates](/xtrader/templates/).

## Legacy fields in stored snapshots

List and Get return the stored snapshot as-is. Configurations created earlier through the web UI may contain legacy fields that the current request schema would reject. Create, update and resume always enforce the latest strict schema, so a snapshot read from the API is not guaranteed to be valid as a write body. Remove or migrate legacy fields before sending a `config` back in a `PATCH`, otherwise expect `validation_error`; a paused legacy config whose stored data fails revalidation will not resume.

## FAQ

### Why must amounts be sent as strings?

Amounts, fees, percentages and durations are decimal strings such as `"0.1"` with at most 12 fractional digits. JSON numbers are not accepted for these fields.

### What happens if I retry a create with the same Idempotency-Key?

The same key with the same body can be retried safely within the 24-hour retention window. The same key with a different body returns `idempotency_conflict`.

### Where do I get the value for If-Match?

From the `ETag` header of the latest single-resource response (create, get, update, pause or resume), for example `"v3"`. It equals the resource `version` field.

### Why is my new config not running?

Configurations are always created paused (`is_active=false`). Call `POST /{id}/resume` with the current `If-Match` to start execution.

### Do labels need If-Match?

No. Labels do not use `ETag` or `If-Match`. Label creation still requires an `Idempotency-Key`.
