P2P trading and all other Bitania services are temporarily disabled until further notice. Only swaps are available for now.
Guide

Swap Aggregator Integration Guide

Access to the Swap API is restricted to verified aggregator partners. To apply for credentials, contact Bitania support (Telegram or SimpleX via the Support button on the site). You receive an API key, an API secret and a login to the partner panel, where you follow your swaps, earnings and payouts.

This guide is for swap aggregators looking to integrate Bitania as a liquidity provider. It covers the architecture, authentication, the full swap lifecycle, and a production integration checklist.

Architecture Overview

Bitania's Swap API provides instant cross-chain cryptocurrency swaps paid from Bitania's own liquidity. Every swap is priced from the live market price (the median of several major exchanges, refreshed every 10 seconds) and paid out from Bitania's wallets, which means:

  • Simple pricing — one market rate and one service fee; no order book depth to walk, no slippage on the quote
  • No network fee deduction — the on-chain cost of the payout is paid by Bitania; the amount quoted is the amount sent
  • No user accounts — swaps are stateless; users never interact with Bitania directly, unless you hand them the public status page of their swap
Your AggregatorBitania
Get Quote
POST /swap/price
← rate + amounts
Price at the live market rate, apply the service fee, check limits and liquidity
Show to User
Create Swap
POST /swap/create
← deposit address
Generate a fresh deposit address
Start the payment window
User Sends Crypto
Poll Status
POST /swap/status
← DONE + tx hash
Detect & confirm the deposit, credit it at the applicable rate, send the output to the destination

Supported Currencies

Instant swaps run on 9 currencies across 7 chains. These are the only codes that can take a deposit or receive a payout (figures are live: confirmations and minimums are what Bitania has configured right now):

CodeCoinNetworkConfirmationsDefault minimumStatus
BTCBTCBitcoin network10.001 BTCavailable
LTCLTCLitecoin network20.1 LTCavailable
XMRXMRMonero network50.05 XMRavailable
TRXTRXTron network20100 TRXavailable
ETHETHEthereum network120.01 ETHavailable
SOLSOLSolana network10.5 SOLavailable
AVAXAVAXAvalanche C-Chain301 AVAXavailable
USDTTRCUSDTTron (TRC20)2020 USDTavailable
USDTERCUSDTEthereum (ERC20)1250 USDTavailable

Query POST /v1/swap/currencies for live availability — each currency reports recv and send flags, its precision, reqConfirmations and min. Always trust the live endpoint over this table: a currency can be temporarily disabled (node maintenance) and confirmation counts or minimums can be tuned.

A /swap/price or /swap/create request naming any other code is rejected with Unknown currency code. The codes are case-insensitive and the spellings USDT-TRC20 / USDT-ERC20 are accepted as aliases, but always send the codes above.

Supported Pairs

Every permutation of the nine currencies is swappable, in both directions. There is no routing: Bitania holds every coin and pays the output from its own wallet, so each pair is direct. To discover the pairs, their current availability and minimums, call POST /v1/swap/pairs:

// POST /v1/swap/pairs  →  data:
[
  { "from": "BTC", "to": "USDTTRC", "type": "direct", "via": [], "available": true, "fixed": true, "min": 0.001 },
  { "from": "XMR", "to": "LTC",     "type": "direct", "via": [], "available": true, "fixed": true, "min": 0.05 },
  { "from": "TRX", "to": "XMR",     "type": "direct", "via": [], "available": true, "fixed": true, "min": 100 }
]

A pair can be put under maintenance (available: false) or be limited by Bitania's liquidity in the destination coin; the quote's from.max tells you the largest amount that can be swapped right now.

Authentication

All Swap API requests use HMAC-SHA256 signatures (v2 scheme) — no JWT tokens or user accounts involved. Every request carries four headers:

X-API-KEY:       btn_your_key   (your API key)
X-API-SIGN:      lowercase hex HMAC-SHA256 of the signing string (below)
X-API-TIMESTAMP: unix seconds, within 60s of server time
X-API-NONCE:     unique per request, single-use

Signing the Request

The signature is not over the raw body. Build a canonical signing string from four newline-joined fields, then HMAC it with your API secret:

{timestamp}\n{METHOD}\n{path}\n{sha256_hex(body)}
  1. timestamp — current unix time in seconds (the same value you send in X-API-TIMESTAMP)
  2. METHOD — the uppercase HTTP verb, POST for all swap endpoints
  3. path — the request path including the /v1 prefix, e.g. /v1/swap/price (no host, no query string)
  4. sha256_hex(body) — hex SHA-256 of the exact bytes you send in the body (the hash of an empty string for requests without a body)
  5. Compute HMAC-SHA256(api_secret, signing_string) as a lowercase hex digest and send it in X-API-SIGN
  • Sign the canonical string, not the body. Joining is exactly three \n (LF) characters, in the order above.
  • Hash the exact bytes you send. If you use json= in Python's requests library (which re-serializes), the body hash won't match — always use data= with the pre-serialized string.
  • Keep your clock NTP-synced. A timestamp more than 60s off the server clock is rejected — this is the #1 cause of intermittent Invalid signature failures.
  • Never reuse a nonce. Generate a fresh one per request, including retries — replaying a nonce within 120s is rejected even with a valid signature.
  • X-API-VERSION is optional. If you do send it, it must be exactly 2.
  • IP whitelist. If your key is restricted to certain IPs, requests from elsewhere fail with Invalid credentials — deliberately indistinguishable from an unknown key.

Python

import hmac, hashlib, json, time, uuid, requests

API_KEY = "btn_your_key"
API_SECRET = "bts_your_secret"
HOST = "https://bitania.com"

def swap_request(endpoint, payload):
    path = f"/v1/swap/{endpoint}"
    body = json.dumps(payload)                          # serialize once
    ts = str(int(time.time()))                          # unix seconds
    nonce = uuid.uuid4().hex                            # unique per request
    body_hash = hashlib.sha256(body.encode()).hexdigest()
    signing_string = f"{ts}\nPOST\n{path}\n{body_hash}"
    signature = hmac.new(
        API_SECRET.encode(), signing_string.encode(), hashlib.sha256
    ).hexdigest()
    return requests.post(
        HOST + path,
        headers={
            "X-API-KEY": API_KEY,
            "X-API-SIGN": signature,
            "X-API-TIMESTAMP": ts,
            "X-API-NONCE": nonce,
            "Content-Type": "application/json",
        },
        data=body,  # NOT json=payload
    ).json()

Node.js

const crypto = require("crypto");
const axios = require("axios");

const API_KEY = "btn_your_key";
const API_SECRET = "bts_your_secret";
const HOST = "https://bitania.com";

async function swapRequest(endpoint, payload) {
  const path = `/v1/swap/${endpoint}`;
  const body = JSON.stringify(payload);
  const ts = Math.floor(Date.now() / 1000).toString();   // unix seconds
  const nonce = crypto.randomUUID();                      // unique per request
  const bodyHash = crypto.createHash("sha256").update(body).digest("hex");
  const signingString = `${ts}\nPOST\n${path}\n${bodyHash}`;
  const signature = crypto
    .createHmac("sha256", API_SECRET)
    .update(signingString)
    .digest("hex");

  const { data } = await axios.post(HOST + path, body, {
    headers: {
      "X-API-KEY": API_KEY,
      "X-API-SIGN": signature,
      "X-API-TIMESTAMP": ts,
      "X-API-NONCE": nonce,
      "Content-Type": "application/json",
    },
    validateStatus: () => true,   // errors come back as {code: 1, msg} with a 4xx status
  });
  return data;
}

Bash / curl

API_KEY="btn_your_key"
API_SECRET="bts_your_secret"
HOST="https://bitania.com"
REQ_PATH="/v1/swap/price"

BODY='{"from":"BTC","to":"USDTTRC","amount":0.1,"direction":"from","type":"float"}'
TS=$(date +%s)
NONCE=$(cat /proc/sys/kernel/random/uuid)
BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 | awk '{print $NF}')
SIGNING_STRING=$(printf '%s\nPOST\n%s\n%s' "$TS" "$REQ_PATH" "$BODY_HASH")
SIGN=$(printf '%s' "$SIGNING_STRING" | openssl dgst -sha256 -hmac "$API_SECRET" | awk '{print $NF}')

curl -X POST "$HOST$REQ_PATH" \
  -H "Content-Type: application/json" \
  -H "X-API-KEY: $API_KEY" \
  -H "X-API-SIGN: $SIGN" \
  -H "X-API-TIMESTAMP: $TS" \
  -H "X-API-NONCE: $NONCE" \
  -d "$BODY"

Integrations written against the original v1 scheme sign the raw body onlyHMAC-SHA256(api_secret, body) — and send just X-API-KEY + X-API-SIGN (no timestamp or nonce). Those requests keep working unchanged, but they have no replay protection: new integrations should follow the v2 scheme above.

Swap Lifecycle

1. Get a Quote

quote = swap_request("price", {
    "type": "float",
    "from": "BTC",
    "to": "USDTTRC",
    "amount": 0.1,
    "direction": "from",
})
# quote["data"]["to"]["amount"] = estimated USDT output (service fee already taken off)
# quote["data"]["from"]["min"] / ["max"] = min/max send amounts
# quote["data"]["fee"] = {"percent": 0.25, "amount": 21.73}

Direction:

  • "from" — "I want to send 0.1 BTC, how much USDT will I get?"
  • "to" — "I want to receive 5000 USDT, how much BTC do I need?" (always a fixed-rate quote, because the user asked for an exact amount)

2. Create the Swap

swap = swap_request("create", {
    "type": "float",
    "from": "BTC",
    "to": "USDTTRC",
    "amount": 0.1,
    "direction": "from",
    "toAddress": "TAzsQ9Gx8eqFNFSKbeXrbi45CuVPHzA8wr",
    "refundAddress": "bc1q...",   # strongly recommended
})
# swap["data"]["id"] = "k7m3px9qw2" — save this
# swap["data"]["token"] = "uYx9j2k3Lm4nOp5qRs6tUw" — optional, treat as a secret
# swap["data"]["from"]["address"] = deposit address to show the user
# swap["data"]["time"]["left"] = seconds until expiration
# swap["data"]["url"] = public status page you may hand to the user

Payment window: 60 minutes for both rate types today. Bitania can tune it, so always drive your countdown from data.time.left (seconds) in the create/status response rather than hard-coding the figure. A swap without a deposit at the end of the window becomes EXPIRED.

A deposit that is broadcast inside the window but confirms slowly is still honoured: once we have seen the transaction (PENDING) the swap no longer expires. A floating swap is credited at the market rate of the moment the deposit reaches the required confirmations; a fixed swap keeps its locked rate.

3. User Deposits Funds

Show the user:

  • The deposit address (data.from.address) — and the network (data.from.network): USDT must be sent on the right chain
  • The exact amount to send (data.from.amount)
  • Time remaining (data.time.left)

4. Poll for Status

By default, status calls are authorized by ownership: the swap must have been created with your API key (or through your referral link). Existing integrations need nothing else. You can also pass the optional token (from the create response) in the body; see Cross-aggregator status views.

import time

while True:
    status = swap_request("status", {
        "id": "k7m3px9qw2",
        # "token": "uYx9j2k3Lm4nOp5qRs6tUw",   # optional — see below
    })
    state = status["data"]["status"]

    if state == "DONE":
        tx_hash = status["data"]["to"]["tx"]["id"]
        print(f"Complete! Output tx: {tx_hash}")
        break
    elif state in ("EXPIRED", "REFUNDED"):
        print(f"Terminal: {state}")
        break
    elif state == "FAILED":
        # Under manual review: Bitania resolves it to DONE or REFUNDED — keep polling (slower is fine)
        print("Under review:", status["data"]["issue"]["reason"])
    elif state == "RATE_CHANGED":
        # Fixed-rate deposit differs from the order — see below
        break
    elif state == "REFUNDING" and status["data"]["issue"]["needsRefundAddress"]:
        # We want to refund but have no address — ask the user, see below
        break

    time.sleep(15)

Status Flow

NEW → PENDING → CONFIRMING → EXCHANGING → SENDING → DONE
                                 ↓ (fixed rate, deposit ≠ order)      ↓ (below minimum / more than we hold)
                            RATE_CHANGED                           REFUNDING → REFUNDED
                           ↙            ↘
                    EXCHANGING        REFUNDING → REFUNDED
                        ↓
                      DONE
StatusMeaning
NEWAwaiting deposit
PENDINGDeposit detected, 0 confirmations
CONFIRMINGDeposit confirming on the blockchain (from.tx.confirmations / from.reqConfirmations)
EXCHANGINGDeposit confirmed and credited; payout being prepared
SENDINGOutput broadcast to the destination
DONEComplete — to.tx.id has the output tx hash, rateActual the applied rate
EXPIREDNo deposit received in time
REFUNDEDFunds returned to the refund address (refund.tx)
RATE_CHANGEDFixed-rate deposit differs from the order: the user accepts the re-quote or asks for a refund (see below)
REFUNDINGDeposit going back: user's choice, or automatic (below minimum / more than we hold); may be waiting for a refund address
FAILEDUnder manual review by Bitania; resolves to DONE or REFUNDED without your action

5. Handle RATE_CHANGED and REFUNDING

RATE_CHANGED happens when a fixed-rate deposit doesn't match the ordered amount (beyond the 0.5 % tolerance). The status response tells you why (issue.type is LESS or MORE) and what the user would get now (proposed.amount at proposed.rate, fixed-rate fee unchanged). Present the user with two choices:

# Continue the swap at the current market rate for the actually deposited amount
swap_request("emergency", {
    "id": "k7m3px9qw2",
    "choice": "EXCHANGE",
})

# OR refund the deposit (minus the refund fee — see Fee Structure)
swap_request("emergency", {
    "id": "k7m3px9qw2",
    "choice": "REFUND",
    "address": "bc1q...",  # required if no refundAddress was set at creation
})

REFUNDING also happens without a decision: a deposit below the pair minimum (issue.type BELOW_MINIMUM) or larger than what we can pay out right now (NO_LIQUIDITY) is never executed and goes back automatically, minus the refund fee. With a refundAddress on the swap that needs nothing from you; without one, issue.needsRefundAddress is true and you must collect an address from the user and send it:

status = swap_request("status", {"id": "k7m3px9qw2"})
if status["data"]["status"] == "REFUNDING" and status["data"]["issue"]["needsRefundAddress"]:
    swap_request("emergency", {"id": "k7m3px9qw2", "choice": "REFUND", "address": "bc1q..."})

Floating-rate swaps never enter RATE_CHANGED: whatever arrives is converted at the market rate of its confirmation, and additional deposits to the same address while the swap is open are credited the same way. If your integration prefers that behaviour for fixed-rate swaps too, ask Bitania to enable auto_execute_on_mismatch on your key: mismatches are then executed at the current rate without the acceptance step. The minimum and liquidity rules apply to both rate types.

/swap/emergency (alias /swap/resolve) accepts the same optional token body field as /swap/status.

Manual review (FAILED)

Occasionally a swap is placed under manual review — for example when a payout failed on-chain and an operator has to look at it. Such a swap is FAILED, with the reason in issue.reason. The EXCHANGE and REFUND actions are not available for it and return an "under manual review" message.

These holds are resolved by Bitania operations — the swap subsequently moves to DONE or REFUNDED. No action is required from your integration: surface a neutral "processing / under review" state to the customer and keep polling /swap/status. Contact support if a swap stays under review for an unusually long time.

Cross-aggregator status views

Sometimes a customer who initiated a swap through your aggregator wants to track it on a different surface — without you sharing your API credentials. Two options:

  • Every swap has a status page on bitania.com; its address is the url field of the create/status response. The link carries the swap's token, so the customer can open it on any device; a visitor without the token (or a scraper walking ids) is asked for the swap's receive address first. Hand itto the customer as-is.
  • Every successful /swap/create response returns a token: a URL-safe per-swap secret (22 characters, 128 bits of entropy). Any API key that presents it may call /swap/status, /swap/advance and /swap/emergency for that swap.

How the dual authorization works:

  • If token is provided and matches the swap's stored token (constant-time compare), the request is authorized regardless of which key created the swap.
  • If token is omitted (or doesn't match), the ownership check applies: the swap must belong to the calling key.
  • Anything else is a 404 Swap not found, so swap ids cannot be probed.
# Your aggregator polling its own swap — no token needed
swap_request("status", {"id": "k7m3px9qw2"})

# Another key (e.g. a second integration of yours) reading the same swap
swap_request("status", {"id": "k7m3px9qw2", "token": "uYx9j2k3Lm4nOp5qRs6tUw"})

Response Format

All responses follow:

{
  "code": 0,
  "msg": "",
  "data": { ... }
}
  • code: 0 — success (HTTP 200, or 201 for a created swap)
  • code: 1 — error; msg has the reason and the HTTP status says which kind: 400 validation or business rule, 401 authentication, 404 unknown swap, 429 rate limit

Rate Types

Floating (default) — 0.25 % fee

The output amount is estimated. The deposit is converted at the market rate of the moment it reaches the required confirmations; the swap's slippage field shows the difference to the quote in percent (negative when the user got less). Lower fee, and never an emergency for a mismatching deposit.

Fixed — 0.5 % fee

The rate is locked when the swap is created and honoured for any deposit within ±0.5 % of the ordered amount. A deposit outside that tolerance is not executed blindly: the swap goes to RATE_CHANGED with a re-quote, and the user (through you) chooses EXCHANGE or REFUND. Requires fixed_rate_enabled on your API key — contact us to enable. Quotes by receive amount ("direction": "to") are always fixed-rate.

Fee Structure

  • Service fee: 0.25 % (floating) or 0.5 % (fixed) of the output amount, taken from the quote. Returned as fee in every quote and swap response.
  • Aggregator revenue share: configurable per API key (a share of the service fee, or a share of the swap volume). Earnings are credited to your partner account in USD when a swap completes; you see them in the partner panel and can withdraw them in any supported coin at market rate, without a fee.
  • Network fee (payout): not deducted. Bitania pays the on-chain cost of the payout. The amount quoted is the amount sent.
  • Refund fee: when a deposit is sent back (the user chose REFUND, or it was below the minimum / more than we could pay out), a refund fee of 1 % of the deposit is withheld; the on-chain cost of the refund transaction is paid by Bitania. The user receives deposit − fee, and refund.tx shows the transaction.
"fee": {
  "percent": 0.25,
  "amount": 21.73
}

Rate Limits

Per API key and endpoint:

EndpointLimit
/swap/currencies60/min
/swap/pairs60/min
/swap/price120/min
/swap/create30/min
/swap/status120/min
/swap/advance120/min
/swap/emergency30/min

Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (unix time). On 429 responses, respect the Retry-After header (also retry_after in the body) and implement exponential backoff.

Error Handling

Common errors you should handle:

ErrorCauseAction
Invalid signatureWrong signing string, body-hash mismatch, clock skew >60s, or reused/missing nonceSign the canonical {ts}\n{METHOD}\n{path}\n{sha256_hex(body)} string; NTP-sync your clock; send a fresh nonce every request
Invalid credentialsUnknown key, disabled key, or IP not whitelisted — deliberately indistinguishableCheck the key value, that it is active, and your source IP
Missing required fields: …Body incomplete or a field has the wrong typeValidate before calling
Minimum is 100 TRXAmount below the pair minimumShow from.min from the quote or /swap/pairs
Maximum right now is …Output larger than Bitania's current liquidity in the destination coinShow "temporarily unavailable for this amount"; retry with a smaller amount or later
This pair is under maintenancePair or coin disabled by BitaniaHide the pair; refresh /swap/pairs
USDT payouts are temporarily unavailableOur wallet for the destination coin is empty or unreachable: every pair paying it out closes automatically (available: false in /swap/pairs)Hide the pair; refresh /swap/pairs and retry later
Pricing is temporarily unavailableMarket price feed staleRetry after a few seconds
That is not a valid USDT addressBad destination (or refund) address for the chainValidate addresses client-side, mind the network
Fixed rate is not enabled for your API keyFixed quote (or direction: "to") without the permissionUse "type": "float" with "direction": "from", or ask us to enable fixed rate
Swap not foundWrong id, or a swap of another key without its tokenCheck the stored swap id

Dry-Run Mode

Dry-run mode lets you test your full integration end-to-end against the live API without using real funds. Dry-run swaps are quoted with the real market price and validated like real orders, but skip all blockchain interactions — the deposit address is a dummy, nothing is watched and nothing is sent.

Use dry-run mode as your first integration step. It validates your HMAC signing, request format, response parsing, and status-polling UI before you touch real crypto.

Enabling

Ask your Bitania contact to enable dryrun_enabled on your API key. Once enabled, you can create dry-run swaps alongside real swaps using the same credentials.

Creating a Dry-Run Swap

Pass "dryrun": true in the body of POST /swap/create:

swap = swap_request("create", {
    "type": "float",
    "from": "BTC",
    "to": "USDTTRC",
    "amount": 0.1,
    "direction": "from",
    "toAddress": "TAzsQ9Gx8eqFNFSKbeXrbi45CuVPHzA8wr",
    "dryrun": True,
})
# swap["data"]["id"] = "DRk7m3px9q" — DR-prefixed id
# swap["data"]["dryrun"] = True
# swap["data"]["from"]["address"] = dummy deposit address (not real)

Dry-run swap ids always start with DR, making them easy to distinguish from real swaps.

Advancing Through the Lifecycle

Since no real deposit will arrive, dry-run swaps stay in NEW forever unless you advance them. Use POST /swap/advance to step through each lifecycle stage one at a time:

# Advance: NEW → PENDING
result = swap_request("advance", {"id": "DRk7m3px9q"})
# result["data"]["status"] = "PENDING"

# Advance again: PENDING → CONFIRMING
result = swap_request("advance", {"id": "DRk7m3px9q"})
# result["data"]["status"] = "CONFIRMING"

The full progression is:

NEW → PENDING → CONFIRMING → EXCHANGING → SENDING → DONE

Each advance populates realistic simulated data:

TransitionSimulated data
NEW → PENDINGFake deposit txid, 0 confirmations
PENDING → CONFIRMINGFirst confirmation
CONFIRMING → EXCHANGINGAll required confirmations met
EXCHANGING → SENDINGPayout initiated
SENDING → DONEFake output txid, completion timestamp, rateActual

Calling advance on a DONE swap returns an error.

Polling Status

POST /swap/status works identically for dry-run swaps — use the same polling logic you'd use for real swaps:

status = swap_request("status", {"id": "DRk7m3px9q"})
# status["data"]["dryrun"] = True
# status["data"]["status"] = current state

What Dry-Run Does NOT Do

  • Generate a real deposit address (returns a dummy nobody controls)
  • Watch any blockchain or send any transaction
  • Enter RATE_CHANGED / REFUNDING or expire
  • Affect your aggregator stats, volumes or earnings
  • Appear in Bitania's dashboards or in your partner panel

Full Dry-Run Integration Test

# 1. Create dry-run swap
swap = swap_request("create", {
    "type": "float",
    "from": "BTC",
    "to": "USDTTRC",
    "amount": 0.1,
    "direction": "from",
    "toAddress": "TAzsQ9Gx8eqFNFSKbeXrbi45CuVPHzA8wr",
    "dryrun": True,
})
swap_id = swap["data"]["id"]
assert swap["data"]["dryrun"] is True
assert swap_id.startswith("DR")

# 2. Advance through all stages
for expected in ["PENDING", "CONFIRMING", "EXCHANGING", "SENDING", "DONE"]:
    result = swap_request("advance", {"id": swap_id})
    assert result["data"]["status"] == expected

# 3. Verify final state
final = swap_request("status", {"id": swap_id})
assert final["data"]["status"] == "DONE"
assert final["data"]["to"]["tx"]["id"] is not None
print(f"Dry-run swap {swap_id} completed successfully")

Referral links

Besides the API, swaps started on bitania.com through your referral link (https://bitania.com/?ref=your_refcode, shown in the partner panel) are attributed to you as well and appear in the same statistics. Attribution is remembered for 30 days in the visitor's browser.

Integration Checklist

  1. Get API credentials — contact us for your btn_ API key, bts_ secret and partner-panel login
  2. Enable dry-run mode — ask us to set dryrun_enabled on your key
  3. Implement HMAC v2 signing — sign the canonical {ts}\n{METHOD}\n{path}\n{sha256_hex(body)} string, send X-API-TIMESTAMP + a fresh X-API-NONCE, NTP-sync your clock, and use data= not json=
  4. Test with dry-run swaps — create dry-run swaps and advance them through the full lifecycle before using real funds
  5. Cache currencies and pairs — call /swap/currencies and /swap/pairs on startup, refresh every few hours
  6. Build the quote flow — call /swap/price, show the output and fee to the user; respect from.min / from.max
  7. Build swap creation — call /swap/create, always pass refundAddress, store id and token
  8. Build status polling — poll every 10–30 s, handle all status transitions, show url to the user if you like
  9. Handle RATE_CHANGED and REFUNDING — present the EXCHANGE/REFUND choice (with proposed) on RATE_CHANGED, and collect a refund address when issue.needsRefundAddress is true; or ask us for auto_execute_on_mismatch
  10. Go live — switch from dry-run to real swaps (just remove "dryrun": true)