Quickstart
This page takes you from a Kotova account to a first order, one step at a time. It assumes your integration runs on a server: the API key is a secret, and it belongs there rather than in a browser or a mobile app.
The examples swap 0.05 BTC on the Bitcoin network for USDT on TRON. Every path is
relative to the base URL https://api.kotova.io/x/v1.
From an account to a first order
Section titled “From an account to a first order”-
Prepare your account. Sign in at app.kotova.io with an account whose e-mail address is confirmed. A disposable address cannot be used for API access. Then:
- turn on two-factor authentication with an authenticator app, under Personal data in your account;
- open the account API page and choose Generate referral code. This creates your affiliate profile and accepts the affiliate terms. Your API key belongs to this profile, and the orders you create with it are attributed to it.
-
Create your API key. On the account API page, accept the API Terms of Use, choose Create API key and enter the 6-digit code from your authenticator app. Backup codes cannot be used here.
The page then shows your secret key, which starts with
ktv_sk_. It is shown once and never again, so copy it straight into your server’s secret store. The examples on these pages read it from an environment variable namedKOTOVA_API_KEY. See Authentication. -
Check that the API answers.
GET /healthneeds no key. The JavaScript and Python snippets also set up what the later steps use: the base URL, theAuthorizationheader, and placeholders for your user’s IP address, your user’s receiving and refund addresses, and your own status display.Terminal window curl -sS https://api.kotova.io/x/v1/healthconst BASE = 'https://api.kotova.io/x/v1';const auth = { Authorization: `Bearer ${process.env.KOTOVA_API_KEY}` };// Placeholders: replace them with your user's values and your own UI.const endUserIp = '93.184.216.34'; // your user's public IP addressconst userTronAddress = 'REPLACE_WITH_YOUR_USERS_TRON_ADDRESS'; // your user's own addressconst userBtcAddress = 'REPLACE_WITH_YOUR_USERS_BTC_ADDRESS'; // your user's own address, for a refundconst showStatus = (order) => console.log(order.id, order.status);// Keyless endpoints: send no key.const health = await (await fetch(`${BASE}/health`)).json();console.log(health.status); // "ok"import osimport requestsBASE = "https://api.kotova.io/x/v1"API_KEY = os.environ["KOTOVA_API_KEY"]AUTH = {"Authorization": f"Bearer {API_KEY}"}# Placeholders: replace them with your user's values and your own UI.end_user_ip = "93.184.216.34" # your user's public IP addressuser_tron_address = "REPLACE_WITH_YOUR_USERS_TRON_ADDRESS" # your user's own addressuser_btc_address = "REPLACE_WITH_YOUR_USERS_BTC_ADDRESS" # your user's own address, for a refunddef show_status(order):print(order["id"], order["status"])# Keyless endpoints: send no key.health = requests.get(f"{BASE}/health", timeout=10).json()print(health["status"]) # "ok"An excerpt of the response:
{"status": "ok","api_version": "v1","time": "2026-11-10T10:15:04Z","catalog_version": "394da0f044c4"}To make this call from your browser, use the console in Check availability.
-
Find the currency and network codes.
GET /currencieslists every currency with the networks it can be sent on (send) and received on (receive). A quote names each side bycurrencyandnetwork. Both endpoints on this step need no key.Terminal window curl -sS https://api.kotova.io/x/v1/currenciesconst { data } = await (await fetch(`${BASE}/currencies`)).json();for (const currency of data) {for (const leg of currency.networks) {if (leg.send) console.log(currency.currency, leg.network, leg.name);}}data = requests.get(f"{BASE}/currencies", timeout=10).json()["data"]for currency in data:for leg in currency["networks"]:if leg["send"]:print(currency["currency"], leg["network"], leg["name"])To see what one currency can be swapped into, ask
GET /pairswithfromandfrom_network:Terminal window curl -sS "https://api.kotova.io/x/v1/pairs?from=BTC&from_network=btc"const pairs = await (await fetch(`${BASE}/pairs?from=BTC&from_network=btc`)).json();for (const pair of pairs.data) {console.log(pair.to.currency, pair.to.network, pair.rate_types.join(', '));}pairs = requests.get(f"{BASE}/pairs", params={"from": "BTC", "from_network": "btc"}, timeout=10).json()["data"]for pair in pairs:print(pair["to"]["currency"], pair["to"]["network"], ", ".join(pair["rate_types"]))See Currencies and icons.
-
Request a quote.
POST /quoteasks every eligible source and returns the best offer.rate_typeis required (variableorfixed). Send your user’s public IP address asend_user_ip: some sources only quote when they have it, and the quote says which ones were skipped without it. Uselangfor the language of the source notice.Terminal window curl -sS https://api.kotova.io/x/v1/quote \-H "Authorization: Bearer $KOTOVA_API_KEY" \-H "Content-Type: application/json" \-d '{"from": {"currency": "BTC", "network": "btc"},"to": {"currency": "USDT", "network": "trx"},"amount": "0.05","amount_type": "from","rate_type": "variable","end_user_ip": "93.184.216.34","lang": "en"}'const quoteRes = await fetch(`${BASE}/quote`, {method: 'POST',headers: { ...auth, 'Content-Type': 'application/json' },body: JSON.stringify({from: { currency: 'BTC', network: 'btc' },to: { currency: 'USDT', network: 'trx' },amount: '0.05',amount_type: 'from',rate_type: 'variable',end_user_ip: endUserIp, // your user's public IP, such as '93.184.216.34'lang: 'en',}),});const quote = await quoteRes.json();if (!quoteRes.ok) throw new Error(`${quote.error.code}: ${quote.error.message}`);res = requests.post(f"{BASE}/quote", headers=AUTH, timeout=30, json={"from": {"currency": "BTC", "network": "btc"},"to": {"currency": "USDT", "network": "trx"},"amount": "0.05","amount_type": "from","rate_type": "variable","end_user_ip": end_user_ip, # your user's public IP, such as "93.184.216.34""lang": "en",})quote = res.json()if not res.ok:raise RuntimeError(f"{quote['error']['code']}: {quote['error']['message']}")An excerpt of the response:
{"quote_id": "q_5mJc2Vq9XkT3nR8bYw1LdP","expires_at": "2026-11-10T10:17:03Z","rate_type": "variable","from": { "currency": "BTC", "network": "btc", "amount": "0.05" },"to": { "currency": "USDT", "network": "trx", "amount": "3389.8" },"requirements": {"refund_address": "optional","to_memo": "not_supported","refund_memo": "not_supported"},"provider": {"name": "Kotova X","icon": {"url": "https://cdn.kotova.io/images/brand/kotova-x/icon.svg","fallback_url": "https://assets.kotova.io/images/brand/kotova-x/icon.svg"}},"source": {"slug": "sideshift","name": "SideShift","notice": {"text": "The exchange service is provided by SideShift. Creating an order confirms your agreement with the SideShift rules.","link_text": "SideShift rules","link_url": "https://sideshift.ai/legal","lang": "en"}},"fallback_sources": [{"source": {"slug": "changenow","name": "ChangeNOW","notice": {"text": "The exchange service is provided by ChangeNOW. Creating an order confirms your agreement with the ChangeNOW rules.","link_text": "ChangeNOW rules","link_url": "https://changenow.io/terms-of-use","lang": "en"}},"from": { "amount": "0.05" },"to": { "amount": "3376.2" },"requirements": { "refund_address": "optional" }}],"warnings": []}The
quote_idcan be used for one order, untilexpires_at: 120 seconds after the source answered for avariablequote, 60 seconds for afixedone. See Quotes. -
Show the quote with the required notices. Show your user the amounts, with Kotova X named as the provider, by name and icon (
provider.name,provider.icon). Before your user confirms, show the source notice:source.notice.text, with the words inlink_textlinked tolink_url. Show the notice of each of the quote’sfallback_sourcesas well, by the same rules: when the quote’s source cannot take the order, it is placed with the first of them that can. The fallback notices may follow the quote’s notice, including in an expandable section placed directly next to it. All of this is mandatory. See Display requirements. -
Create the order. When your user confirms, send
POST /orderswith thequote_id, the address your user receives on (to_address) and, if you like, your own reference (partner_order_id).We recommend sending
refund_addresstoo: your user’s address on the network they send from, withrefund_memowhen that address needs a memo. Where the source takes it with the order, it goes to the source then, so a refund needs no extra step, and the quote’s fallback sources that need one stay eligible. It is required only when the quote saysrequirements.refund_address: "required". Ask for it in the same form as the address your user receives on (see Asking for the addresses).Send an
Idempotency-Keyheader and store it with your own order. If the request times out, send it again with the same key and the same body: you get the order that was created, never a second one. Creating an order costs 50 units of your rate limit.If you do not show the notices of the fallback sources, add
"retry_next_best_source": falseto the body: the order is then placed only with the quote’s source. See Next best source.Terminal window curl -sS https://api.kotova.io/x/v1/orders \-H "Authorization: Bearer $KOTOVA_API_KEY" \-H "Content-Type: application/json" \-H "Idempotency-Key: 3f1c9a52-7d0e-4b8a-9c61-2e5f4a7b8d90" \-d '{"quote_id": "q_5mJc2Vq9XkT3nR8bYw1LdP","to_address": "REPLACE_WITH_YOUR_USERS_TRON_ADDRESS","refund_address": "REPLACE_WITH_YOUR_USERS_BTC_ADDRESS","partner_order_id": "shop-order-1042"}'import { randomUUID } from 'node:crypto';const idempotencyKey = randomUUID(); // store it with your order; reuse it on a retryconst orderRes = await fetch(`${BASE}/orders`, {method: 'POST',headers: { ...auth, 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey },body: JSON.stringify({quote_id: quote.quote_id,to_address: userTronAddress, // your user's own addressrefund_address: userBtcAddress, // recommended: where a refund goespartner_order_id: 'shop-order-1042',}),signal: AbortSignal.timeout(100_000),});const order = await orderRes.json();if (!orderRes.ok) throw new Error(`${order.error.code}: ${order.error.message}`);import uuididempotency_key = str(uuid.uuid4()) # store it with your order; reuse it on a retryres = requests.post(f"{BASE}/orders",headers={**AUTH, "Idempotency-Key": idempotency_key},timeout=100,json={"quote_id": quote["quote_id"],"to_address": user_tron_address, # your user's own address"refund_address": user_btc_address, # recommended: where a refund goes"partner_order_id": "shop-order-1042",},)order = res.json()if not res.ok:raise RuntimeError(f"{order['error']['code']}: {order['error']['message']}") -
Show the deposit instructions. The API answers
201 Createdwith the order. An excerpt, with the addresses shortened:{"id": "X-7KQ2ZD","status": "waiting_for_deposit","is_final": false,"expires_at": "2026-11-10T11:15:39Z","deposit": {"address": "bc1q9h7g…zq0p2ne","memo": null,"memo_label": null,"amount": "0.05"},"order_url": "https://app.kotova.io/x/X-7KQ2ZD"}Show your user
deposit.amountoffrom.currencyonfrom.network, thedeposit.addressit goes to and, when it is set,deposit.memounder the label indeposit.memo_label. A deposit without its memo may not be credited. The deposit address belongs to the order’s source, and the deposit is due beforeexpires_at. When the order was placed with a fallback source, itssourcenames it, its amounts are that source’s, andwarningshassource_fallback: show the order’s source and amounts, not the quote’s.You may give your user the
order_urlif you are happy for them to follow and resolve the order on Kotova: it opens the order page on app.kotova.io, which they unlock with the address they receive on. To keep them in your app, show the order yourself fromGET /orders/{id}, and, until the API has endpoints for an order’s choices, send them tostatus_detail.resolve_urlonly when an order needs a decision. -
Follow the order until it is final. Read the order with
GET /orders/{id}every 15 to 30 seconds and show your user itsstatus, untilis_finalistrue. Each read costs 1 unit; the API refreshes the order from its source at most once every 10 seconds while it is in progress, and once a minute while it is only being followed up (see Reading an order).Terminal window curl -sS https://api.kotova.io/x/v1/orders/X-7KQ2ZD \-H "Authorization: Bearer $KOTOVA_API_KEY"const sleep = (s) => new Promise((resolve) => setTimeout(resolve, s * 1000));async function followOrder(id) {for (;;) {const res = await fetch(`${BASE}/orders/${id}`, { headers: auth });const body = await res.json();if (res.ok) {showStatus(body); // your own UIif (body.is_final) return body;} else if (res.status !== 429 && res.status < 500) {throw new Error(`${body.error.code}: ${body.error.message}`);}await sleep(Number(res.headers.get('Retry-After')) || 20);}}import timedef follow_order(order_id):while True:res = requests.get(f"{BASE}/orders/{order_id}", headers=AUTH, timeout=30)body = res.json()if res.ok:show_status(body) # your own UIif body["is_final"]:return bodyelif res.status_code != 429 and res.status_code < 500:raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")time.sleep(int(res.headers.get("Retry-After", 20)))An order in
action_requiredneeds attention:status_detailsays why and whether your user can act on the order page. When the reason ispayout_on_hold, write to api@kotova.io with the order id. Every status, and what to show for it, is in Order statuses.
Testing before you go live
Section titled “Testing before you go live”A sandbox is not available yet. Requesting a quote has no side effects, so you can build and test everything up to the order without creating anything. An order you create is a real order at its source:
- If nobody sends the deposit, the order expires and nothing is owed (creating it still used 50 units of your rate limit). Until it expires it counts towards your account’s limit of open orders waiting for a deposit (see Order caps).
- To test the whole flow, create an order for a small amount, send the deposit from a wallet you control and receive the payout at an address you control.
- The Try it consoles on these pages call the production API with your key. The order console asks you to confirm every time before it creates an order.