Bring exchange to your product: get a quote, create an order and track execution. API methods, examples and integration steps in one place.
Create a key in your account and include it in the header of each request.
https://co-re.ioThe key goes in the Authorization header as a Bearer token. It is shown once at creation. It can be revoked and rotated without downtime. Up to five active keys per account. There is no sandbox: a key is live from the first minute.
curl https://co-re.io/api/v1/quotes \
-H "Authorization: Bearer <api_key>" \
-H "Content-Type: application/json" \
-d '{"send":{"asset":"BTC","network":"BTC"},
"receive":{"asset":"USDT","network":"TRON"},
"amount":"0.05"}'Amounts are decimal strings, for example "0.0213585". Do not convert them to floating-point numbers: this loses precision.
Specify both the asset code and network. USDT on TRON and USDT on Ethereum require different addresses and are not interchangeable.
Use code to handle the error and message to inform the user.
{ "error": { "code": "pair_unavailable", "message": "..." } }Times are UTC, ISO 8601.
What CORE can exchange right now: code, network, which direction works, whether a memo is required, and the precision. It asks no provider, so it is cheap and cacheable. Integration starts here: without it a coin list has to be hardcoded.
{
"currencies": [
{
"asset": "USDT",
"network": "TRON",
"name": "Tether USD",
"sendEnabled": true,
"receiveEnabled": true,
"memoRequired": false,
"decimals": 6
},
{
"asset": "XRP",
"network": "XRP",
"name": "XRP",
"sendEnabled": false,
"receiveEnabled": true,
"memoRequired": true,
"decimals": 6
}
],
"count": 2
}The minimum and maximum for a pair, both denominated in the send asset. Derived by asking providers with two deliberately out-of-range amounts, so it costs two provider round-trips: cache it per pair, do not call it per keystroke. null means no provider stated one - not zero, and not unlimited.
{
"send": { "asset": "BTC", "network": "BTC" },
"receive": { "asset": "USDT", "network": "TRON" }
}{
"limits": {
"send": { "asset": "BTC", "network": "BTC" },
"receive": { "asset": "USDT", "network": "TRON" },
"min": "0.0007",
"max": null
}
}The same question create will ask, from the same module - validate first and create will not then refuse the address. It also answers the memo question, which is the one that loses coins: an XRP payout without its tag is unrecoverable, and knowing that before the form is submitted is worth more than knowing it after. Structural only: shape, charset, length. Not a checksum, and not whether anybody controls it.
{
"asset": "XRP",
"network": "XRP",
"address": "rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH"
}{
"valid": true,
"networkKnown": true,
"memoRequired": true,
"checked": "structure"
}Rewards in USD. pending is the reward on completed swaps whose income is not confirmed yet; it cannot be withdrawn. On confirmation it moves to available. available accounts for holds, reservations and recoveries. held means the funding source needs review. Read the withdrawal minimum from balance.minPayout. monthVolume is the volume counted towards your share this calendar month (UTC), in USD; your share grows with it. toNextLevel is the counted volume this month still needed to raise your share, null at the top level. Both are null when there is nothing to show; the money fields are unaffected.
{
"balance": {
"currency": "USD",
"available": "0",
"pending": "0",
"held": "0",
"reserved": "0",
"paid": "0",
"minPayout": "10",
"monthVolume": "3120.45",
"toNextLevel": "11879.55"
}
}The best live rate for a pair and amount across active providers. Creates nothing and moves nothing. The executor is named where its own terms require it, and marked undisclosed otherwise.
{
"send": { "asset": "BTC", "network": "BTC" },
"receive": { "asset": "USDT", "network": "TRON" },
"amount": "0.05"
}{
"quote": {
"send": { "asset": "BTC", "network": "BTC", "amount": "0.05" },
"receive": { "asset": "USDT", "network": "TRON", "amount": "3212.44" },
"rateType": "float",
"expiresAt": "2026-08-24T09:14:44Z",
"executor": { "disclosed": false, "executorTermsAccepted": true },
"coreMarkup": "0",
"partnerMarkup": "0",
"comparedExecutors": 3
}
}The /quotes response carries executor: by default only the fact that there is one and that its terms are accepted. The name, disclosure and passport (who holds the deposit, rate modes, terms, and our own history with that provider) come back where the executor's terms require naming it. comparedExecutors is a count by default: how many executors answered. Whether you show any of it to your own user is your decision, not our requirement. The executor names are switched on per key, for a partner that really does show the comparison.
Completed exchanges made with your key can earn a reward. Where the executor withholds a markup on the order, the reward for that exchange is the markup. An amount stays pending until the income is confirmed, then it can be withdrawn. Earnings and payouts are shown in your partner account and at /api/v1/balance. partnerMarkup is always the string "0" and does not show earnings.
Your deals, newest first. Parameters: limit (default 50, maximum 100) and cursor from the previous page's nextCursor. partnerMarkup is a legacy quote field, currently the string zero; it does not establish earnings. commissionUsd is the posted USD accrual. null means no accrual has posted, including for a completed swap awaiting income evidence. It does not mean zero earnings. Use /balance for the withdrawable amount.
{
"deals": [
{
"id": "7f1c...",
"status": "COMPLETED",
"createdAt": "2026-08-24T09:12:44Z",
"send": { "asset": "BTC", "network": "BTC", "amount": "0.05" },
"receive": { "asset": "USDT", "network": "TRON", "amount": "3180.31", "final": true },
"partnerMarkup": "0",
"commissionUsd": null
}
],
"nextCursor": "MjAyNi0wOC0yNFQwOToxMjo0NFp8N2YxYw"
}Picks the executor, re-quotes with it and returns the deposit address your client pays into. A rate from /quotes is never accepted: it is always recomputed.
{
"send": { "asset": "BTC", "network": "BTC" },
"receive": { "asset": "USDT", "network": "TRON" },
"amount": "0.05",
"payoutAddress": "TQ5...",
"refundAddress": "bc1q...",
"memo": null,
"campaignId": null
}{
"deal": {
"id": "7f1c...",
"status": "AWAITING_DEPOSIT",
"send": { "asset": "BTC", "network": "BTC", "amount": "0.05" },
"receive": { "asset": "USDT", "network": "TRON", "amount": "3212.44" },
"deposit": {
"address": "bc1q...",
"tag": null,
"amount": "0.05",
"deadline": "2026-07-27T10:15:00Z"
},
"trackUrl": "https://<site>/track/7f1c..."
}
}If creation is unconfirmed, the API returns 202 with status REVIEW and no deposit address. This is not confirmation that the exchange was created. Do not request a deposit from the customer; an operator will review the order.
The current state of your deal, reconstructed from the event log. While the deposit is awaited the response repeats the deposit details.
{
"deal": {
"id": "7f1c...",
"status": "ACTIVE",
"send": { "asset": "BTC", "network": "BTC", "amount": "0.05" },
"receive": { "asset": "USDT", "network": "TRON", "amount": "3212.44", "final": false },
"deposit": {
"address": "bc1q...",
"tag": null,
"amount": "0.05",
"deadline": "2026-08-24T09:42:44Z"
},
"trackUrl": "https://<site>/track/7f1c..."
}
}The trackUrl in a response is the public deal page. You can hand it to your client: no account is needed, and it shows the status, the hashes and a support form.
Create answers AWAITING_DEPOSIT or REVIEW, which describe the execution step. Below are the statuses that reading the deal returns. Poll the GET and branch on that.
| DRAFT | Recorded; the provider order is not confirmed yet. |
| ACTIVE | In flight: awaiting the deposit, confirmations, the exchange or the payout. |
| COMPLETED | The payout has been sent to the client. · terminal |
| FAILED | The exchange did not happen and no refund was made. · terminal |
| REFUNDED | The provider returned the coins to the refund address. · terminal |
| EXPIRED | The deposit did not arrive in time. · terminal |
A status change arrives by webhook when one is set up. GET /api/v1/deals/:id is the source of truth: without a webhook, poll it every 15-30 seconds and stop on a terminal status.
A webhook is set per API key in your account: one https address on port 443 per key. Saving it shows the signing secret, once. The same place sends a test event, issues a new secret and lists recent deliveries with their response code. Get a key
deal.status_changed A deal created with this key moved to another status. previousStatus and status describe that transition. deal is the same object GET /api/v1/deals/:id returns, as of the first delivery attempt.
{
"id": "0b6c3a1e-5d2f-4c8a-9e61-2f4b7d9c1a30",
"type": "deal.status_changed",
"createdAt": "2026-08-24T09:40:02.113Z",
"data": {
"previousStatus": "ACTIVE",
"status": "COMPLETED",
"deal": {
"id": "7f1c...",
"status": "COMPLETED",
"trackUrl": "https://<site>/track/7f1c...",
"send": { "asset": "BTC", "network": "BTC", "amount": "0.05" },
"receive": { "asset": "USDT", "network": "TRON", "amount": "3180.31", "final": true }
}
}
}reward.available An accrual on your account became withdrawable. The amount in USD only: the event carries no rate. Sent to every webhook of the account. The amount in the example is illustrative.
{
"id": "5e0a9d44-7b1c-4f3e-8a26-c91d0f6b2e77",
"type": "reward.available",
"createdAt": "2026-08-31T00:01:10.502Z",
"data": {
"reward": { "id": "c2d4...", "amount": "1.25", "currency": "USD" }
}
}webhook.test Sent from the button in your account. Signed exactly like real events.
{
"id": "9a7e2c11-3f64-4d0b-b5a8-0e2c6f1d4b93",
"type": "webhook.test",
"createdAt": "2026-08-24T09:12:44.000Z",
"data": {}
}Every request carries X-Core-Signature: t=<unix seconds>,v1=<hex>. v1 is the HMAC-SHA256 of the string "t.body" with your secret. Verify it against the raw request body before parsing JSON, and reject a t older than 300 seconds.
const crypto = require("node:crypto");
// rawBody: the request body exactly as received, before any JSON parsing.
function verifyCoreWebhook(rawBody, header, secret, toleranceSeconds = 300) {
if (typeof header !== "string" || header === "") return false;
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const timestamp = Number(parts.t);
if (!Number.isInteger(timestamp) || !/^[0-9a-f]{64}$/.test(parts.v1 ?? "")) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest();
return crypto.timingSafeEqual(Buffer.from(parts.v1, "hex"), expected);
}import hashlib, hmac, re, time
# raw_body: the request body bytes exactly as received, before any JSON parsing.
def verify_core_webhook(raw_body: bytes, header, secret: str, tolerance: int = 300) -> bool:
if not isinstance(header, str) or not header:
return False
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not re.fullmatch(r"[0-9]{1,12}", t) or not re.fullmatch(r"[0-9a-f]{64}", v1):
return False
if abs(time.time() - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)<?php
// $rawBody = file_get_contents('php://input');
// $header = $_SERVER['HTTP_X_CORE_SIGNATURE'] ?? '';
function verifyCoreWebhook(string $rawBody, string $header, string $secret, int $tolerance = 300): bool {
$parts = [];
foreach (explode(',', $header) as $pair) {
[$key, $value] = array_pad(explode('=', $pair, 2), 2, '');
$parts[$key] = $value;
}
$t = $parts['t'] ?? '';
$v1 = $parts['v1'] ?? '';
if (!preg_match('/^[0-9]{1,12}$/', $t) || !preg_match('/^[0-9a-f]{64}$/', $v1)) {
return false;
}
if (abs(time() - (int) $t) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
return hash_equals($expected, $v1);
}Delivered means any 2xx within 5 seconds. Redirects are not followed and count as a failure. Retries after: 30 s, 2 min, 10 min, 30 min, 1 h, 3 h, 6 h, 12 h, then delivery stops. One event can arrive more than once and out of order: keep the ids you processed and confirm the status with GET. The address must be public: nothing is sent to private networks or metadata addresses.
Idempotency-Key is required when creating an exchange. Without it, the API returns 400. A retry with the same key returns the original deal without creating another.
curl https://co-re.io/api/v1/deals \
-H "Authorization: Bearer <api_key>" \
-H "Idempotency-Key: 6b1f2c58-0a1e-4b3f-9d21-8c0f4a7e5b12" \
-H "Content-Type: application/json" \
-d '{"send":{"asset":"BTC","network":"BTC"},
"receive":{"asset":"USDT","network":"TRON"},
"amount":"0.05",
"payoutAddress":"TQ5...",
"refundAddress":"bc1q..."}'Generate the key per exchange attempt, not per HTTP request. Retry with the same key until you get a final answer. Format: 8 to 128 characters, ASCII letters, digits and . _ : - , starting with a letter or digit; a UUID works.
| HTTP | code | |
|---|---|---|
| 400 | invalid_request | The body is not JSON, or a required field is missing: send, receive, amount, payoutAddress, refundAddress. |
| 400 | invalid_amount | amount is not a positive decimal string. |
| 400 | idempotency_key_required | POST /deals came without an Idempotency-Key header. Without it a retry would create a second exchange. |
| 401 | unauthorized | No Authorization header, or the key is invalid or revoked. |
| 403 | ip_not_allowed | The key has an IP allowlist and the request came from another address. Change the list in the cabinet, API section. |
| 404 | not_found | No deal with that id among the ones your key created. Another tenant's deal also returns 404: existence is not leaked. |
| 409 | idempotency_key_conflict | The same Idempotency-Key against a different exchange: another pair, amount or address. Generate one key per exchange attempt and reuse it only for retries of that attempt. Since migration 085 this no longer fires merely because the comparison routed elsewhere on the retry. |
| 503 | pricing_unavailable | Fee settings could not be verified. An exchange is not created with an unverified fee. Retry shortly; keep the same Idempotency-Key when creating a deal. |
| 503 | quote_unavailable | The selected quote could not be refreshed before creating the exchange. No create request was sent to the provider. Retry with the same Idempotency-Key. |
| 503 | replay_executor_unavailable | A retry with the same Idempotency-Key: the exchange already exists, but the executor holding it cannot be reached right now. Retry with the SAME key shortly. A new key would create a second real order. |
| 503 | order_reservation_failed | The exchange could not be confirmed. Retry with the SAME key: a retry never creates a second order. If an order was created at the provider after all, the retry returns it or the exchange goes to operator review. |
| 422 | executor_refused_request | The executor validated the request and refused it: a bad address, an amount out of range, an unsupported asset, or an expired quote. No order exists and no deposit is needed. Correct it and submit with a NEW Idempotency-Key. This used to return 202 REVIEW telling you to hold a deposit for an order that was never coming. |
| 422 | amount_below_minimum | The amount is below the provider minimum for this pair. Increase it. |
| 422 | amount_above_maximum | The amount is above the provider maximum for this pair. Reduce it. This case used to arrive as pair_unavailable, which was false. |
| 422 | pair_unavailable | No active provider quotes this pair right now, or the instrument was disabled between the quote and the create. |
| 422 | invalid_payout_address | payoutAddress does not look like an address on the receive network. Checked before any provider call. |
| 422 | invalid_campaign | campaignId is not an active campaign of your account. Checked before any provider call. |
| 422 | invalid_refund_address | refundAddress does not look like an address on the send network. |
| 422 | memo_required | The receive network requires a memo or tag and the memo field was not sent. |
| 422 | refund_memo_unsupported | The send network identifies the beneficiary by memo and there is no refund memo field. refundAddress is mandatory, so this send asset is not supported. |
| 503 | balance_unavailable | The balance cannot be read right now. An error, not a zero: a false zero on a money endpoint is worse than a 5xx. |
| 429 | rate_limited | More than 60 requests per minute on one key. |
Not supported: fixed rates, cancelling a created exchange, specifying an exact receive amount, separate network-fee breakdowns or a sandbox. Active providers use floating rates.