# AnonChange partner API

> Private, no-KYC crypto swaps (Bitcoin, Ethereum, Monero, Litecoin, Solana and stablecoins) for wallets, aggregators and sites. Signed JSON API, PGP-signed letter of guarantee per order, affiliate program. Human-readable version: https://anonchange.com/docs

## Overview

- Base URL: `https://anonchange.com/api/partner/v1`
- Every endpoint is a `POST` with a JSON body and `Content-Type: application/json`. Send `{}` when an endpoint has no fields. Unknown fields are rejected.
- Every request is signed with your API secret (see Authentication).
- Success responses are plain JSON. Errors are `{ "error": "message" }` with an HTTP status code.
- Amounts are decimal strings (`"0.01"`), never numbers, in whole coins. Percentages and counts are numbers. Times are ISO 8601 in UTC.
- Coins are identified by their code from `currencies`, for example `BTC`, `XMR` or `USDT_SOL` (USDT on Solana).

| Status | Meaning |
| --- | --- |
| 400 | Invalid input: unknown or missing field, bad amount or address, pair not available, body not JSON. |
| 401 | Missing or invalid authentication headers, expired timestamp, reused nonce or wrong signature. |
| 403 | Your partner account is disabled. |
| 404 | Order not found (or not one of yours), or no such endpoint (every endpoint is a POST). |
| 409 | The order is not in a state that allows this action, or it has to wait a moment. |
| 413 | Body larger than 16 KB. |
| 415 | Content-Type is not application/json. |
| 429 | Rate limit reached. Wait for the Retry-After header's seconds and retry. |
| 500 | Our error. Retry later; for create, retry with the same Idempotency-Key. |
| 503 | Temporarily unavailable: no fresh rates, not enough reserve, exchange paused or too many unpaid orders. |

Retry 429 (after its `Retry-After` seconds) and 503 later with a new nonce and timestamp. Rarely, a 429 or 503 comes from our proxy as plain text instead of JSON.

## Getting started

1. Create a partner account at https://anonchange.com/partners (self-service, password only; the account ID is the username and a lost password cannot be reset).
2. You get an API key and an API secret. The secret is shown once; it can be replaced in the dashboard (the old one then stops working).
3. Call `currencies` and `price` to test the signature, then create orders and poll their `status`.

## Authentication

Every request carries four headers:

| Header | Value |
| --- | --- |
| X-API-KEY | Your API key. |
| X-API-TIMESTAMP | Current unix time in seconds. Must be within 60 seconds of our clock. |
| X-API-NONCE | A random string of 16-64 characters (A-Z, a-z, 0-9, - and _). Each nonce works once within 120 seconds. |
| X-API-SIGN | Lowercase hex HMAC-SHA256 of the signed string, keyed with your API secret. |

The signed string:

```text
${timestamp}\n${nonce}\nPOST\n${path}\n${idempotencyKey}\n${sha256hex(body)}
```

The nonce must match X-API-NONCE. The idempotencyKey must match Idempotency-Key, or be empty when absent;
keep that empty line in the signed string. Retrying requires a fresh nonce and signature, with the same idempotency key.

- `\n` is a single newline character, with no newline at the end.
- `path` is the request path including `/api/partner/v1`, for example `/api/partner/v1/price`: exactly the path requested. Send no query string and no trailing slash. The method is always `POST`.
- `sha256hex(body)` is the lowercase hex SHA-256 of the exact body bytes sent. Serialize the JSON once and send that same string.
- Keep the secret on a server; never in a browser, an app or a public repository.

A complete signed request (the Node.js and Python versions define a `call` helper used in later examples):

### curl

```bash
API_KEY="ak_your_api_key"
API_SECRET="your_api_secret"
REQ_PATH="/api/partner/v1/price"
BODY='{"from":"BTC","to":"XMR","rateType":"float","amount":"0.01"}'

TS=$(date +%s)
NONCE=$(openssl rand -hex 16)
IDEMPOTENCY_KEY=""
BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | sed 's/^.* //')
SIGN=$(printf '%s\n%s\nPOST\n%s\n%s\n%s' "$TS" "$NONCE" "$REQ_PATH" "$IDEMPOTENCY_KEY" "$BODY_HASH" \
  | openssl dgst -sha256 -hmac "$API_SECRET" -hex | sed 's/^.* //')

curl -sS -X POST "https://anonchange.com$REQ_PATH" \
  -H "Content-Type: application/json" \
  -H "X-API-KEY: $API_KEY" \
  -H "X-API-TIMESTAMP: $TS" \
  -H "X-API-NONCE: $NONCE" \
  -H "X-API-SIGN: $SIGN" \
  --data-binary "$BODY"
```

### Node.js

```javascript
// anonchange.mjs (Node.js 18 or newer)
import { createHash, createHmac, randomBytes } from "node:crypto";

const BASE = "https://anonchange.com";
const API_KEY = process.env.ANONCHANGE_API_KEY;
const API_SECRET = process.env.ANONCHANGE_API_SECRET;

export async function call(endpoint, payload = {}, headers = {}) {
  const path = `/api/partner/v1/${endpoint}`;
  const body = JSON.stringify(payload); // sign exactly the bytes you send
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const nonce = randomBytes(16).toString("hex");
  const extraHeaders = new Headers(headers);
  const idempotencyKey = extraHeaders.get("Idempotency-Key") ?? "";
  const bodyHash = createHash("sha256").update(body).digest("hex");
  const sign = createHmac("sha256", API_SECRET)
    .update(`${timestamp}\n${nonce}\nPOST\n${path}\n${idempotencyKey}\n${bodyHash}`)
    .digest("hex");

  const response = await fetch(BASE + path, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-API-KEY": API_KEY,
      "X-API-TIMESTAMP": timestamp,
      "X-API-NONCE": nonce,
      "X-API-SIGN": sign,
      ...Object.fromEntries(extraHeaders),
    },
    body,
  });
  const data = await response.json().catch(() => ({}));
  if (!response.ok) throw new Error(`${response.status} ${data.error ?? response.statusText}`);
  return data;
}

const price = await call("price", { from: "BTC", to: "XMR", rateType: "float", amount: "0.01" });
console.log(price);
```

### Python

```python
# anonchange.py (Python 3, standard library only)
import hashlib, hmac, json, os, secrets, time
import urllib.error, urllib.request

BASE = "https://anonchange.com"
API_KEY = os.environ["ANONCHANGE_API_KEY"]
API_SECRET = os.environ["ANONCHANGE_API_SECRET"]

def call(endpoint, payload=None, headers=None):
    path = "/api/partner/v1/" + endpoint
    body = json.dumps(payload or {}, separators=(",", ":")).encode()  # sign exactly these bytes
    timestamp = str(int(time.time()))
    nonce = secrets.token_hex(16)
    extra_headers = {k.lower(): v for k, v in (headers or {}).items()}
    idempotency_key = extra_headers.get("idempotency-key", "")
    message = f"{timestamp}\n{nonce}\nPOST\n{path}\n{idempotency_key}\n{hashlib.sha256(body).hexdigest()}"
    sign = hmac.new(API_SECRET.encode(), message.encode(), hashlib.sha256).hexdigest()
    request = urllib.request.Request(BASE + path, data=body, method="POST", headers={
        "Content-Type": "application/json",
        "X-API-KEY": API_KEY,
        "X-API-TIMESTAMP": timestamp,
        "X-API-NONCE": nonce,
        "X-API-SIGN": sign,
        **extra_headers,
    })
    try:
        with urllib.request.urlopen(request, timeout=30) as response:
            return json.load(response)
    except urllib.error.HTTPError as error:
        raise RuntimeError(f"{error.code} {error.read().decode()}") from None

print(call("price", {"from": "BTC", "to": "XMR", "rateType": "float", "amount": "0.01"}))
```

The same API as an OpenAPI 3.1 spec: https://anonchange.com/docs/openapi.json. A Postman collection that signs requests itself (set its `apiKey` and `apiSecret` variables): https://anonchange.com/docs/postman.json.

## Endpoints

### currencies

`POST https://anonchange.com/api/partner/v1/currencies` (120 requests per minute per API key). Body: `{}`. All coins with their limits; only coins with `enabled: true` can be exchanged right now.

| Field | Type | Description |
| --- | --- | --- |
| coin, name | string | Coin code (used everywhere else) and display name. |
| decimals | number | Decimals the coin's amounts can have. |
| enabled | boolean | Available for new orders. |
| minDeposit | string | Smallest deposit we exchange. Smaller deposits are refunded. |
| confirmations | number | Confirmations a deposit needs, as an order's requiredConfirmations shows them (blocks to finality on Ethereum and Solana). |
| networkFee | string | Network fee taken from a payout in this coin. |
| usd | string \| null | Current USD price, if known. |
| reserve | string | What we can pay out in this coin right now: the most an order into it can receive. |

```json
[
  {
    "coin": "BTC",
    "name": "Bitcoin",
    "decimals": 8,
    "enabled": true,
    "minDeposit": "0.0002",
    "confirmations": 2,
    "networkFee": "0.00003",
    "usd": "67012.4",
    "reserve": "1.84213"
  },
  {
    "coin": "XMR",
    "name": "Monero",
    "decimals": 12,
    "enabled": true,
    "minDeposit": "0.02",
    "confirmations": 10,
    "networkFee": "0.0002",
    "usd": "163.21",
    "reserve": "412.5"
  }
]
```

### pairs

`POST https://anonchange.com/api/partner/v1/pairs` (120 requests per minute per API key). Body: `{}`. Every pair of enabled coins and the rate types it supports.

```json
[
  {
    "from": "BTC",
    "to": "XMR",
    "rateTypes": [
      "float",
      "fixed"
    ]
  },
  {
    "from": "XMR",
    "to": "BTC",
    "rateTypes": [
      "float",
      "fixed"
    ]
  }
]
```

### price

`POST https://anonchange.com/api/partner/v1/price` (600 requests per minute per API key). A quote for a pair; nothing is reserved.

| Field | Type | Description |
| --- | --- | --- |
| from | string | Coin the customer sends. |
| to | string | Coin the customer receives. |
| rateType | string | float or fixed. |
| amount | string, optional | Amount of the from coin the customer sends. Without it (and without amountOut), amountIn and amountOut are null. |
| amountOut | string, optional | Quote by the amount to receive instead: amountIn becomes the smallest deposit that receives at least this. Not together with amount. |
| markupPercent | number, optional | Your markup for this request, 0 up to the cap shown in your dashboard, at most 2 decimals. Without it, your dashboard markup applies. |

Request:

```json
{
  "from": "BTC",
  "to": "XMR",
  "rateType": "fixed",
  "amount": "0.01"
}
```

Response:

```json
{
  "from": "BTC",
  "to": "XMR",
  "rateType": "fixed",
  "amountIn": "0.01",
  "amountOut": "4.044423745",
  "rate": "410.6217",
  "feePercent": 1.5,
  "networkFee": "0.0002",
  "minDeposit": "0.0002",
  "maxDeposit": "0.45",
  "rateLockMinutes": 10,
  "maxRateDriftPercent": 1.2
}
```

- `amountOut` is what the customer receives after `feePercent` and `networkFee`; `rate` is our exchange rate before fees.
- `feePercent` already includes the partner's own markup.
- `minDeposit` and `maxDeposit` are this pair's deposit limits right now (the maximum follows our reserve of the receiving coin).
- `rateLockMinutes` (fixed only): how long a fixed rate stays locked. `maxRateDriftPercent`: how far the market may move against the quote before the customer is asked.

### create

`POST https://anonchange.com/api/partner/v1/create` (60 requests per minute per API key). Creates an order and returns its deposit address.

| Field | Type | Description |
| --- | --- | --- |
| from, to, rateType | string | As for price. |
| amount | string | Required (or amountOut instead). At least minDeposit. |
| amountOut | string, optional | Instead of amount: the amount the customer should receive. The deposit amount is worked out from it, as in price. |
| markupPercent | number, optional | Your markup for this order, as in price. Saved with the order. |
| webhookUrl | string, optional | https URL that gets a signed POST whenever the order's status changes (see Webhooks). |
| payoutAddress | string | The customer's address for the to coin. |
| refundAddress | string, optional | The customer's address for the from coin. Strongly recommended: without it, refunds go through support. |

Optional header `Idempotency-Key` (8-128 characters, A-Z a-z 0-9 - _), unique per order on your side: a retry with the same key returns the order made the first time, with status 200 instead of 201. The retry's body is not compared, so use a new key for every new order.

Request:

```json
{
  "from": "BTC",
  "to": "XMR",
  "rateType": "fixed",
  "amount": "0.01",
  "payoutAddress": "<customer's XMR address>",
  "refundAddress": "<customer's BTC address>"
}
```

Response (201):

```json
{
  "id": "3f6c1a52-8d1e-4b7a-9c35-2f0e6b9d4a17",
  "status": "awaiting_deposit",
  "from": "BTC",
  "to": "XMR",
  "rateType": "fixed",
  "depositAddress": "<BTC deposit address>",
  "payoutAddress": "<customer's XMR address>",
  "refundAddress": "<customer's BTC address>",
  "quote": {
    "amountIn": "0.01",
    "amountOut": "4.044423745",
    "rate": "410.6217"
  },
  "amountIn": null,
  "amountOut": null,
  "rate": null,
  "feePercent": 1.5,
  "networkFee": "0.0002",
  "requiredConfirmations": 2,
  "deposits": [],
  "payout": null,
  "pendingDeposit": null,
  "continuing": false,
  "actions": {
    "continue": false,
    "refund": false
  },
  "refund": null,
  "note": null,
  "guarantee": "-----BEGIN PGP SIGNED MESSAGE-----\n…\n-----END PGP SIGNATURE-----",
  "rateLockedUntil": "2026-10-19T12:10:00.000Z",
  "expiresAt": "2026-10-19T12:10:00.000Z",
  "createdAt": "2026-10-19T12:00:00.000Z",
  "completedAt": null,
  "viewKey": "<view key>",
  "url": "https://anonchange.com/order/3f6c1a52-8d1e-4b7a-9c35-2f0e6b9d4a17#key=<view key>"
}
```

- The response is the order plus `viewKey` and `url`, returned only on the first 201: store them right away.
- `url` is the customer's order page; the key after `#key=` lets it show the order's addresses and transactions.
- Show the customer `depositAddress`, the amount to send and `expiresAt`. Only the `from` coin on its own network is credited.

### curl

```bash
API_KEY="ak_your_api_key"
API_SECRET="your_api_secret"
REQ_PATH="/api/partner/v1/create"
BODY='{"from":"BTC","to":"XMR","rateType":"fixed","amount":"0.01","payoutAddress":"<customer XMR address>","refundAddress":"<customer BTC address>"}'

TS=$(date +%s)
NONCE=$(openssl rand -hex 16)
IDEMPOTENCY_KEY="checkout-1234"
BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | sed 's/^.* //')
SIGN=$(printf '%s\n%s\nPOST\n%s\n%s\n%s' "$TS" "$NONCE" "$REQ_PATH" "$IDEMPOTENCY_KEY" "$BODY_HASH" \
  | openssl dgst -sha256 -hmac "$API_SECRET" -hex | sed 's/^.* //')

curl -sS -X POST "https://anonchange.com$REQ_PATH" \
  -H "Content-Type: application/json" \
  -H "X-API-KEY: $API_KEY" \
  -H "X-API-TIMESTAMP: $TS" \
  -H "X-API-NONCE: $NONCE" \
  -H "X-API-SIGN: $SIGN" \
  -H "Idempotency-Key: checkout-1234" \
  --data-binary "$BODY"
```

### Node.js

```javascript
const order = await call(
  "create",
  {
    from: "BTC",
    to: "XMR",
    rateType: "fixed",
    amount: "0.01",
    payoutAddress: customer.xmrAddress,
    refundAddress: customer.btcAddress,
  },
  { "Idempotency-Key": checkout.id }, // your own id: a retry returns the same order
);
// Store order.id, order.viewKey and order.url now: the key is returned only once.
```

### Python

```python
order = call(
    "create",
    {
        "from": "BTC",
        "to": "XMR",
        "rateType": "fixed",
        "amount": "0.01",
        "payoutAddress": customer["xmr_address"],
        "refundAddress": customer["btc_address"],
    },
    {"Idempotency-Key": checkout["id"]},  # your own id: a retry returns the same order
)
# Store order["id"], order["viewKey"] and order["url"] now: the key is returned only once.
```

### status

`POST https://anonchange.com/api/partner/v1/status` (600 requests per minute per API key). Body: `{ "id": "3f6c1a52-8d1e-4b7a-9c35-2f0e6b9d4a17" }`. The full order, with addresses, transaction IDs and the letter of guarantee.

| Field | Description |
| --- | --- |
| id | Order ID (UUID). Use it with status, continue and refund. |
| status | Current state, see the order lifecycle below. |
| from, to, rateType | As given at creation. |
| depositAddress | Where the customer sends the coins. Show it while the status is awaiting_deposit. |
| payoutAddress, refundAddress | As given at creation (refundAddress may be null). |
| quote | The quoted amountIn, amountOut and rate at creation. |
| amountIn, amountOut, rate | What was actually received, paid out and applied. Null until the deposit is credited. |
| feePercent, networkFee | Total fee in percent (your markup included) and the network fee taken from the payout. |
| requiredConfirmations | Confirmations a deposit needs before it is credited. |
| deposits | Deposits counted for this order: txid, amount, confirmations, credited, seenAt. |
| pendingDeposit | A deposit seen on the network but not counted yet (display only), or null. |
| payout, refund | The outgoing transaction, or null: status (pending, broadcast, confirmed, failed), amount, txid, broadcastAt, confirmedAt. |
| continuing | true after the customer chose continue, until the order settles at a fresh price (usually within a minute). |
| actions | continue / refund: true when the customer may choose (see Continue or refund). |
| note | A short message for the customer, or null. |
| guarantee | The PGP-signed letter of guarantee, or null if signing failed. |
| rateLockedUntil | Fixed rate: the locked rate applies to a deposit detected before this time (coins whose deposits are only seen once mined, like Bitcoin, get a few extra minutes). Null for float, and after a late deposit. |
| expiresAt | Until when the order waits for the deposit: the rate lock for fixed, 60 minutes (by default) for float. A later deposit is still exchanged. |
| createdAt, completedAt | ISO 8601 timestamps (UTC). completedAt is null until completed. |

```json
{
  "id": "3f6c1a52-8d1e-4b7a-9c35-2f0e6b9d4a17",
  "status": "completed",
  "from": "BTC",
  "to": "XMR",
  "rateType": "fixed",
  "depositAddress": "<BTC deposit address>",
  "payoutAddress": "<customer's XMR address>",
  "refundAddress": "<customer's BTC address>",
  "quote": {
    "amountIn": "0.01",
    "amountOut": "4.044423745",
    "rate": "410.6217"
  },
  "amountIn": "0.01",
  "amountOut": "4.044423745",
  "rate": "410.6217",
  "feePercent": 1.5,
  "networkFee": "0.0002",
  "requiredConfirmations": 2,
  "deposits": [
    {
      "txid": "<deposit txid>",
      "amount": "0.01",
      "confirmations": 2,
      "credited": true,
      "seenAt": "2026-10-19T12:04:12.000Z"
    }
  ],
  "payout": {
    "status": "confirmed",
    "amount": "4.044423745",
    "txid": "<payout txid>",
    "broadcastAt": "2026-10-19T12:31:40.000Z",
    "confirmedAt": "2026-10-19T12:52:03.000Z"
  },
  "pendingDeposit": null,
  "continuing": false,
  "actions": {
    "continue": false,
    "refund": false
  },
  "refund": null,
  "note": null,
  "guarantee": "-----BEGIN PGP SIGNED MESSAGE-----\n…\n-----END PGP SIGNATURE-----",
  "rateLockedUntil": "2026-10-19T12:10:00.000Z",
  "expiresAt": "2026-10-19T12:10:00.000Z",
  "createdAt": "2026-10-19T12:00:00.000Z",
  "completedAt": "2026-10-19T12:52:03.000Z"
}
```

### orders

`POST https://anonchange.com/api/partner/v1/orders` (60 requests per minute per API key). Your API orders, newest first. While `more` is true, request the next page with `before` set to the last id received. The dashboard also lists referral-link orders (no id or amounts, date only: they belong to their customers) and exports everything as CSV.

| Field | Type | Description |
| --- | --- | --- |
| limit | number, optional | 1-100 orders, default 50. |
| before | string, optional | An order id from the previous page: returns the orders older than it. |
| status | string, optional | Only orders in this status (see Order lifecycle). |

Request:

```json
{
  "limit": 2
}
```

Response:

```json
{
  "orders": [
    {
      "id": "3f6c1a52-8d1e-4b7a-9c35-2f0e6b9d4a17",
      "status": "completed",
      "from": "BTC",
      "to": "XMR",
      "rateType": "fixed",
      "amountIn": "0.01",
      "amountOut": "2.93811204",
      "feePercent": 1.5,
      "markupPercent": 0.5,
      "source": "api",
      "earningUsd": "4.84",
      "createdAt": "2026-10-19T10:02:11.000Z",
      "completedAt": "2026-10-19T10:31:40.000Z"
    },
    {
      "id": "9b2e7c40-1f3a-4d6e-8a51-7c0d2e4f6a18",
      "status": "awaiting_deposit",
      "from": "LTC",
      "to": "XMR",
      "rateType": "float",
      "amountIn": null,
      "amountOut": null,
      "feePercent": 1.5,
      "markupPercent": 0,
      "source": "api",
      "earningUsd": null,
      "createdAt": "2026-10-19T09:48:03.000Z",
      "completedAt": null
    }
  ],
  "more": true
}
```

### continue and refund

`POST https://anonchange.com/api/partner/v1/continue` (30 requests per minute per API key) and `POST https://anonchange.com/api/partner/v1/refund` (30 requests per minute per API key). Body: `{ "id": "…" }`.

If the market rate moves against the quote by more than `maxRateDriftPercent` before the deposit is confirmed, the order stops in `refund_required` and waits for the customer. A deposit that cannot be exchanged (below the minimum, or too small to pay out) also stops in `refund_required`, with only `actions.refund`:

- `continue` (when `actions.continue` is true): exchange at the market rate. The order shows `continuing: true` (both actions false) until it settles at a price fetched after the call, usually within a minute.
- `refund` (when `actions.refund` is true): send the deposit back to the order's refund address, minus the network fee.

```json
{
  "id": "3f6c1a52-8d1e-4b7a-9c35-2f0e6b9d4a17",
  "status": "refund_required",
  "continuing": false,
  "actions": {
    "continue": true,
    "refund": true
  },
  "note": "Market rate moved by 1.6% since your quote"
}
```

Both return the updated order (after `continue` still `refund_required`, with `continuing: true`); when the matching action is false the answer is 409. If neither action is true, the order needs support. After `continue` the order goes on like any other (it can still be held for review).

## Webhooks

Pass `webhookUrl` to `create` to get a POST right after creation, whenever the order's status changes and when its deposit is first seen. The `order` is the same JSON as `status` returns.

```json
{
  "event": "order.updated",
  "order": {
    "id": "3f6c1a52-8d1e-4b7a-9c35-2f0e6b9d4a17",
    "status": "confirming",
    "from": "BTC",
    "to": "XMR",
    "rateType": "fixed",
    "depositAddress": "<BTC deposit address>",
    "payoutAddress": "<customer's XMR address>",
    "refundAddress": "<customer's BTC address>",
    "quote": {
      "amountIn": "0.01",
      "amountOut": "4.044423745",
      "rate": "410.6217"
    },
    "amountIn": null,
    "amountOut": null,
    "rate": null,
    "feePercent": 1.5,
    "networkFee": "0.0002",
    "requiredConfirmations": 2,
    "deposits": [],
    "payout": null,
    "pendingDeposit": null,
    "continuing": false,
    "actions": {
      "continue": false,
      "refund": false
    },
    "refund": null,
    "note": null,
    "guarantee": "-----BEGIN PGP SIGNED MESSAGE-----\n…\n-----END PGP SIGNATURE-----",
    "rateLockedUntil": "2026-10-19T12:10:00.000Z",
    "expiresAt": "2026-10-19T12:10:00.000Z",
    "createdAt": "2026-10-19T12:00:00.000Z",
    "completedAt": null
  }
}
```

| Header | Value |
| --- | --- |
| X-Webhook-Timestamp | Unix time in seconds when the request was sent. |
| X-Webhook-Sign | Lowercase hex HMAC-SHA256 of `${timestamp}\n${body}` (the exact body bytes), keyed with your API secret. |
| Content-Type, User-Agent | application/json and AnonChange-Webhook/1. |

- Check the signature with the API secret, reject timestamps older than about 5 minutes, answer 2xx within 10 seconds.
- Deliveries can arrive late or more than once, and a status that passed quickly can be skipped: use the order's `status`, not the order of arrival.
- Failed deliveries (no answer within 10 seconds, or not 2xx) are retried with growing pauses for about 12 hours, then given up.
- Deliveries stop after `completed` or `refunded` was delivered, and 7 days after creation. An `expired` order is still watched (every 10 minutes): a late deposit moves it on.
- Only `https` URLs on port 443 with a public address; redirects are not followed. Deliveries come from changing IP addresses: verify the signature, don't allowlist IPs.
- After rotating the API secret, new deliveries are signed with the new one.

### Node.js

```javascript
// Express: keep the raw body, check the signature and the time, then answer 2xx quickly.
import crypto from "node:crypto";
import express from "express";

const app = express();
app.post("/hooks/anonchange", express.raw({ type: "application/json" }), (req, res) => {
  const timestamp = req.get("X-Webhook-Timestamp");
  const expected = crypto
    .createHmac("sha256", process.env.ANONCHANGE_API_SECRET)
    .update(`${timestamp}\n${req.body}`)
    .digest("hex");
  const given = Buffer.from(req.get("X-Webhook-Sign") ?? "");
  const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
  if (!fresh || given.length !== expected.length || !crypto.timingSafeEqual(given, Buffer.from(expected))) {
    return res.sendStatus(401);
  }
  const { order } = JSON.parse(req.body);
  // update your copy of order.id with order.status ...
  res.sendStatus(204);
});
```

### Python

```python
# Flask
import hashlib, hmac, json, os, time
from flask import Flask, request

app = Flask(__name__)

@app.post("/hooks/anonchange")
def anonchange_hook():
    timestamp = request.headers.get("X-Webhook-Timestamp", "")
    body = request.get_data()
    expected = hmac.new(os.environ["ANONCHANGE_API_SECRET"].encode(), timestamp.encode() + b"\n" + body, hashlib.sha256).hexdigest()
    fresh = timestamp.isdigit() and abs(time.time() - int(timestamp)) < 300
    if not fresh or not hmac.compare_digest(expected, request.headers.get("X-Webhook-Sign", "")):
        return "", 401
    order = json.loads(body)["order"]
    # update your copy of order["id"] with order["status"] ...
    return "", 204
```

## Order lifecycle

Usual path: `awaiting_deposit` → `confirming` → `exchanging` → `sending` → `completed`. An order that received a deposit ends `completed` or `refunded`.

| Status | Meaning | What to show |
| --- | --- | --- |
| awaiting_deposit | Waiting for the customer’s deposit. | Deposit address, amount and the time left until expiresAt. |
| confirming | Deposit seen, waiting for network confirmations. | “Deposit received”, with confirmations against requiredConfirmations. |
| exchanging | Deposit credited, the payout is queued. | “Exchanging”, the final amountIn and amountOut. |
| sending | Payout broadcast, waiting for its confirmation. | “Sending”, with payout.txid. |
| completed | Payout confirmed. Final. | “Completed”, amountOut and payout.txid. |
| expired | No deposit before expiresAt. | “Expired”. A late deposit is still exchanged at the market rate and moves the order on. |
| review | Held for a manual check (for example a large amount or not enough reserve at that moment). | “Under review”. It ends completed or refunded. |
| refund_required | Cannot be exchanged as it is: the rate moved, the deposit was below the minimum, or the payout is not possible. | The customer’s choice if actions.continue or actions.refund is true; otherwise support handles it. |
| refunding | Refund queued or broadcast to the refund address. | “Refunding”, with refund.txid once sent. |
| refunded | Refund sent. Final. | “Refunded”, refund.amount and refund.txid. |
| failed | The payout needs a manual check by our team. | The note, and a link to support. |

- Float: the market rate when the deposit is confirmed; the quote is an estimate. More than `maxRateDriftPercent` worse than the quote: the order stops for the customer's decision.
- Fixed: the quoted rate is locked for `rateLockMinutes` (see `rateLockedUntil`) for a deposit of up to the quoted amount. A larger deposit gets the market rate or the fixed one, whichever is lower; a later deposit gets the market rate (either stops for the customer's decision like a float order if that rate moved too far). If the market moves so sharply that the locked rate cannot be paid, the order is held for `review`.
- `review` is a manual check by our team (for example a large amount); after it the order is completed or refunded.
- Refunds go to the order's `refundAddress`, minus the network fee. Always pass one. A deposit below the minimum is not exchanged: the order stops in `refund_required` for a refund.
- A float order waits 60 minutes (by default) for the deposit, a fixed one as long as its rate lock (`expiresAt`). After that it is `expired`, but a late deposit is still exchanged at the market rate.

## Limits and polling

| Endpoint | Requests per minute per API key |
| --- | --- |
| currencies | 120 |
| pairs | 120 |
| price | 600 |
| create | 60 |
| status | 600 |
| orders | 60 |
| continue, refund | 30 |

- Unpaid open orders per partner are capped; above the cap, create returns 503.
- Up to 3000 requests per minute per IP address reach the partner API.
- A 429 carries a `Retry-After` header (seconds). `currencies` and `pairs` change slowly: cache them for a minute.
- Poll `status` every 15-30 seconds while an order is open; stop at `completed` or `refunded`. Or use webhooks instead of polling.

## Common errors and fixes

Every error is `{ "error": "message" }`. The messages integrations run into most, exactly as the API sends them:

| Status | Error | Fix |
| --- | --- | --- |
| 400 | Body is not valid JSON but content-type is set to 'application/json' | Send valid JSON. An endpoint without fields takes {}. |
| 400 | body must be object | Send a JSON object as the body, at least {}. |
| 400 | body must NOT have additional properties | A field name is wrong or unknown: amount (not amountIn) in price and create, id (not orderId) in status, continue and refund. |
| 400 | body must have required property 'rateType' | Add the named field. Names are case-sensitive. |
| 400 | Enter a valid amount (digits and up to 12 decimals) | Amounts are strings like "0.01", never JSON numbers, with a dot and no thousands separators. Also for amountOut ("body/amountOut must be string"). |
| 400 | Invalid BTC amount (at most 8 decimals) | Round to the coin's decimals from currencies. |
| 400 | body/from must be equal to one of the allowed values | Use the coin code from currencies, in capitals: BTC, XMR, USDT_SOL. |
| 400 | Choose two different coins | from and to must differ. |
| 400 | This pair is not available | One of the coins is disabled right now (enabled: false in currencies). Hide it and check again later. |
| 400 | Send either amount or amountOut, not both | Pick one: what the customer sends (amount) or receives (amountOut). |
| 400 | Orders need an amount | Every order needs amount or amountOut. |
| 400 | Amount must be within the deposit limits | The deposit must be at least minDeposit (from price or currencies). |
| 400 | Invalid XMR payout address | payoutAddress is for the to coin, refundAddress for the from coin ("Invalid BTC refund address"). Mainnet addresses only, as the customer gave them. |
| 400 | Enter a valid payout address | payoutAddress is missing or shorter than 20 characters. |
| 400 | That XMR payout address belongs to this exchange; use an address from your own wallet | A deposit address of ours was pasted as the payout address. Ask the customer for their own. |
| 400 | markupPercent can be at most 2 | Stay within the cap shown in your dashboard. It is a number (0.5), not a string, with at most 2 decimals. |
| 400 | webhookUrl must use https | Only https URLs on port 443 with a public host name or address (also "must use the default https port (443)", "must be a public address"). |
| 400 | Idempotency-Key must be 8-128 letters, digits, - or _ | For example your checkout id, or a UUID. |
| 401 | X-API-KEY, X-API-TIMESTAMP, X-API-NONCE and X-API-SIGN headers are required | Send all four headers on every request, including currencies and pairs. |
| 401 | X-API-TIMESTAMP must be the current unix time in seconds | Use seconds (10 digits), not milliseconds, and keep your server clock synced (NTP): we accept 60 seconds either way. |
| 401 | X-API-NONCE must be 16-64 letters, digits, - or _ | For example 32 random hex characters (openssl rand -hex 16). |
| 401 | X-API-NONCE was already used | Make a new nonce for every request, retries included. |
| 401 | Invalid API key or signature | Usually the signed string differs from the request: the path must include /api/partner/v1 and nothing after it (no query string, no trailing slash); hash the exact body bytes you send, not a re-serialized copy; the method is POST in capitals; no newline at the end; hex digests, not base64. Also check you use the current secret (rotating it ends the old one). |
| 403 | This partner account is disabled | Contact support. |
| 404 | Unknown endpoint GET /api/partner/v1/…: every endpoint is a POST, see /docs | Use POST, the exact endpoint name and no trailing slash. |
| 404 | Order not found | The id is not one of your orders (orders made with another API key or on the site are not visible). In orders, before must be one of your order ids. |
| 409 | This order cannot be continued | Only when the order's actions.continue is true (after a price move). Check status first. |
| 409 | This order cannot be refunded by you; please contact support | Only when actions.refund is true. Otherwise support handles the order. |
| 409 | Payouts are paused right now, try again later | Also "No fresh market rate, try again shortly". Retry after a minute; the customer's choice is kept. |
| 415 | Unsupported Media Type | Set Content-Type: application/json. |
| 429 | Rate limit exceeded, retry in 1 minute | Wait Retry-After seconds. Cache currencies and pairs (they change slowly), poll status every 15-30 seconds or use webhooks. |
| 503 | Rates temporarily unavailable | Also "Exchange is temporarily disabled" and "BTC deposits are temporarily unavailable, try again later". Show the pair as unavailable and retry later. |
| 503 | Insufficient payout liquidity for this amount | The amount is above what we can pay out right now: stay within maxDeposit from price. Also "Fixed-rate exposure limit reached" (try float) and "Payout liquidity is not verified; try again shortly". |
| 503 | Too many unpaid orders open for this API key | Create an order only when the customer is ready to pay; unpaid ones expire. |
| 503 | No deposit address available, try again in a minute | Also "Too many open orders right now, try again later". Retry with the same Idempotency-Key. |

Mistakes without an error:

| Symptom | Fix |
| --- | --- |
| viewKey and url missing from create | A retry with an Idempotency-Key that was used before returns the first order (200) without them. Store them from the first 201. The retry's body is not compared: use a new key per order. |
| Your webhook check fails | Compute the HMAC over the raw body bytes before parsing the JSON, with your current API secret, and the timestamp header as it came. |
| The customer sent the coin on another network | Only the from coin on its own network is credited (USDT_SOL: USDT on Solana, not on Ethereum or Tron). Show the network next to the deposit address. |
| An expired order moves on | A deposit after expiresAt is still exchanged, at the market rate. Keep watching an expired order for a few days, or use webhooks (they continue). |

Stuck? Log the exact signed string and headers sent (never the secret) and compare them with the curl example under Authentication.

## Letter of guarantee

Every order has a PGP cleartext-signed letter in its `guarantee` field with the order's terms. It states that the order is either completed on the terms in the letter or refunded; no KYC; we do not freeze funds (some orders need a manual check, after which they are completed or refunded); refunds go to the refund address minus the network fee (an amount too small to cover it cannot be refunded). Verify it:

```bash
curl -s https://anonchange.com/pgp.txt | gpg --import
# save the order's "guarantee" field as letter.asc, e.g.
jq -r .guarantee order.json > letter.asc
gpg --verify letter.asc
```

The key's fingerprint is on https://anonchange.com/pgp.

## Affiliate program

- Referral links: `https://anonchange.com/?ref=YOUR_CODE`. The visitor's browser keeps the referral for 30 days; the visitor pays the normal price.
- API orders: a share of our fee plus the partner's own optional markup (set in the dashboard or per request with `markupPercent`, capped, included in `feePercent`).
- Share, markup cap and minimum payout are shown in the dashboard. Earnings are counted in USD when an order completes; payouts are requested in the dashboard in any supported coin and arrive minus that coin's network fee.

## Swap widget

No code needed: embed the widget. Visitors get a live quote; "Exchange" opens the swap on AnonChange in a new tab with the partner's referral, so it earns like the referral link.

```html
<iframe
  src="https://anonchange.com/widget?ref=YOUR_CODE&from=BTC&to=XMR"
  title="AnonChange swap"
  width="100%"
  height="400"
  style="border:0;max-width:456px"
></iframe>
```

| Parameter | Meaning |
| --- | --- |
| ref | Your referral code (from the dashboard). Swaps opened from the widget earn like your referral link. |
| from, to | Optional: the coins preselected, e.g. BTC and XMR. |
| amount | Optional: the amount to send, preselected. |

A sandboxed iframe needs `allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox`.
