Документация API

Подключите обмен к своему продукту: получите курс, создайте заявку и отслеживайте исполнение. Здесь — методы API, примеры и порядок интеграции.

Начало

Создайте ключ в кабинете и передавайте его в заголовке каждого запроса.

Базовый URL

https://co-re.io

Порядок подключения

  1. Храните ключ на сервере. Для сайта без собственного интерфейса используйте готовый виджет. Настроить виджет
  2. Загрузите справочник монет, проверьте сети и лимиты, затем запросите курс.
  3. Соберите адрес получения, адрес возврата и нужные memo: memo для получения, refundMemo для возврата. Если адрес возврата принадлежит бирже или кастодиальному сервису, возврат без memo теряется: в этом случае всегда передавайте refundMemo. Создавайте обмен с уникальным Idempotency-Key.
  4. Показывайте реквизиты депозита только при stage AWAITING_DEPOSIT. При UNDER_REVIEW дождитесь проверки. Передайте клиенту trackUrl и опрашивайте статус.

Открыть кабинет партнёра

Ключи

Ключ идёт в заголовке Authorization как Bearer-токен и показывается один раз. Первый ключ можно создать в кабинете, дополнительные и замену — через поддержку CORE, даже после отзыва. Отозвать ключ можно самостоятельно. Тестовой среды нет: ключ боевой с первой минуты.

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"}'

На ключ 600 запросов в минуту на чтение: справочник, границы, проверка адреса, курсы и чтение сделок. GET /pairs — 30 в минуту. Создание обменов и /balance — 60 в минуту, отдельным счётом, так что опрос курсов не отнимает лимит у создания. Пакет /quotes/bulk стоит 1 + ceil(n/5) запросов чтения, пакеты идут по одному. Если нужно больше, напишите нам: лимит ключа поднимается, оба счёта растут в той же пропорции. Опрос статуса раз в 15-30 секунд по каждой открытой сделке входит в лимит чтения. С одного IP допускается 120 запросов в минуту с неверным или отозванным ключом; удачные запросы сюда не входят. Ответ 429 несёт заголовок Retry-After: через сколько секунд повторить.

Соглашения

Суммы передаются десятичными строками, например "0.0213585". Не преобразуйте их в числа с плавающей точкой: это приводит к потере точности.

Указывайте код актива и сеть. USDT в TRON и USDT в Ethereum требуют разных адресов и не взаимозаменяемы.

Используйте code для обработки ошибки и message для сообщения пользователю.

{ "error": { "code": "pair_unavailable", "message": "..." } }

Время в UTC, формат ISO 8601.

Справочник монет

GET/api/v1/currencies

Что CORE умеет обменивать прямо сейчас: код, сеть, в какую сторону работает, нужен ли memo и точность. Для сопоставления со своим списком монет: адрес контракта, chainId для EVM, как сеть называет memo, ссылка на транзакцию в эксплорере ({txHash} заменить на хеш) и иконка. contractAddress указан только там, где мы сверили его с сетью; null значит нативную монету или адрес, которого у нас нет. Не опрашивает исполнителей, поэтому дёшев и кэшируется. У сетей с memo sendEnabled = true, только если исполнитель принимает memo для адреса возврата (refundMemo в POST /deals).

Доступ: Боевой ключ.

Ответ 200

{
  "currencies": [
    {
      "asset": "USDT",
      "network": "ETH",
      "name": "Tether USD",
      "networkName": "Ethereum",
      "sendEnabled": true,
      "receiveEnabled": true,
      "memoRequired": false,
      "decimals": 6,
      "memoName": null,
      "contractAddress": "0xdAC17F958D2ee523a2206206994597C13D831ec7",
      "chainId": 1,
      "explorerTxUrl": "https://etherscan.io/tx/{txHash}",
      "iconUrl": "https://co-re.io/coins/usdt.svg"
    },
    {
      "asset": "XRP",
      "network": "XRP",
      "name": "XRP",
      "networkName": "XRP Ledger",
      "sendEnabled": true,
      "receiveEnabled": true,
      "memoRequired": true,
      "decimals": 6,
      "memoName": "Destination tag",
      "contractAddress": null,
      "chainId": null,
      "explorerTxUrl": "https://xrpscan.com/tx/{txHash}",
      "iconUrl": "https://co-re.io/coins/xrp.svg"
    }
  ],
  "count": 2
}

Может вернуть

401 unauthorized403 ip_not_allowed503 service_unavailable429 rate_limited500 internal_error

Направления обмена

GET/api/v1/pairs

Все направления, которые CORE может котировать прямо сейчас: актив и сеть с обеих сторон, плюс сохранённый минимум. Исполнителей не опрашивает: список строится из того же справочника, по которому идёт котировка, и хранится в памяти 5 минут, поэтому вызов дешёвый. Свой лимит: 30 вызовов в минуту на ключ. Ответ несёт ETag и Cache-Control: пришлите If-None-Match, и без изменений придёт 304 без тела. Фильтр ?send=АКТИВ:СЕТЬ (например, BTC:BTC) оставляет направления из одного актива. min — наименьший минимум, который назвал хоть один исполнитель, в активе отправки; null, если его не измеряли последние 12 часов. max пока всегда null: живые границы даёт /limits. Котировка остаётся главной: пара из списка может не пройти, если исполнитель отказал. Одно направление — около 110 байт, а без фильтра в ответе все направления сразу, так что просите сжатие (Accept-Encoding: gzip) или берите фильтр. Сеть с memo бывает стороной отправки, только если направление обслуживает исполнитель, который принимает memo для адреса возврата (так же, как sendEnabled в /currencies). Если адрес возврата принадлежит бирже или кастодиальному сервису, для такого направления передавайте refundMemo в POST /deals: без memo возврат теряется.

Доступ: Боевой ключ.

Ответ 200

{
  "pairs": [
    {
      "send":    { "asset": "BTC",  "network": "BTC" },
      "receive": { "asset": "USDT", "network": "TRON" },
      "min": "0.0007",
      "max": null
    },
    {
      "send":    { "asset": "BTC", "network": "BTC" },
      "receive": { "asset": "XRP", "network": "XRP" },
      "min": null,
      "max": null
    }
  ],
  "count": 2,
  "updatedAt": "2026-09-30T09:14:44.000Z"
}

Может вернуть

400 invalid_request401 unauthorized403 ip_not_allowed503 service_unavailable429 rate_limited500 internal_error

Границы суммы

POST/api/v1/limits

Минимум и максимум по паре, обе цифры в активе отправки. Считается опросом провайдеров двумя заведомо негодными суммами, поэтому стоит два обращения к провайдерам; полный ответ по паре хранится 5 минут. Кэшируйте на пару, не дёргайте на каждое нажатие клавиши; минимум всех пар сразу есть в GET /pairs. null означает «ни один провайдер не назвал» — не ноль и не «без ограничений».

Доступ: Боевой ключ.

Запрос

{
  "send":    { "asset": "BTC",  "network": "BTC" },
  "receive": { "asset": "USDT", "network": "TRON" }
}

Ответ 200

{
  "limits": {
    "send":    { "asset": "BTC",  "network": "BTC" },
    "receive": { "asset": "USDT", "network": "TRON" },
    "min": "0.0007",
    "max": null
  }
}

Может вернуть

400 invalid_request401 unauthorized403 ip_not_allowed503 service_unavailable422 pair_unavailable429 rate_limited500 internal_error

Проверка адреса

POST/api/v1/validate-address

Тот же вопрос, который задаст создание обмена, и тем же модулем — проверив адрес заранее, можно быть уверенным, что создание из-за него не откажет. Заодно отвечает про memo: выплата в XRP без тега невозвратна, и узнать это до отправки формы дороже, чем после. Проверка структурная: форма, алфавит, длина. Не контрольная сумма и не «этим адресом кто-то владеет».

Доступ: Боевой ключ.

Запрос

{
  "asset":   "XRP",
  "network": "XRP",
  "address": "rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH"
}

Ответ 200

{
  "valid": true,
  "networkKnown": true,
  "memoRequired": true,
  "checked": "structure"
}

Может вернуть

400 invalid_request401 unauthorized403 ip_not_allowed503 service_unavailable429 rate_limited500 internal_error

Баланс комиссии

GET/api/v1/balance

Вознаграждение в USD. pending - вознаграждение по завершённым обменам, доход по которым ещё не подтверждён; к выводу оно недоступно. После подтверждения сумма переходит в available. available учитывает удержания, резервы и суммы к восстановлению. held означает проверку источника. Минимум вывода берите из balance.minPayout. monthVolume - объём, засчитанный в вашу долю за календарный месяц (UTC), в USD; с ним растёт доля. toNextLevel - сколько засчитанного объёма в этом месяце осталось до повышения доли, null на максимальном уровне. Оба поля null, когда показать нечего; денежные поля от этого не зависят.

Доступ: Боевой ключ.

Ответ 200

{
  "balance": {
    "currency": "USD",
    "available": "0",
    "pending": "0",
    "held": "0",
    "reserved": "0",
    "paid": "0",
    "minPayout": "100",
    "monthVolume": "3120.45",
    "toNextLevel": "11879.55"
  }
}

Может вернуть

401 unauthorized403 ip_not_allowed503 service_unavailable503 balance_unavailable429 rate_limited500 internal_error

Курс

POST/api/v1/quotes

Лучший живой курс на пару и сумму среди активных провайдеров. Ничего не создаёт и не двигает. Исполнитель назван там, где этого требуют его собственные условия, иначе помечен как нераскрытый. Только плавающий курс и сумма отправки, как в /deals: rateType и amountSide можно не передавать, другое значение даст unsupported_option. min и max — сохранённые границы пары, те же, что в GET /pairs, в активе отправки; null — «здесь не известно», не ноль и не «без ограничений». Если сеть отправки требует memo, курс сравнивается только среди исполнителей, которые принимают memo для адреса возврата: туда же POST /deals отправит обмен.

Доступ: Боевой ключ.

Запрос

{
  "send":    { "asset": "BTC",  "network": "BTC" },
  "receive": { "asset": "USDT", "network": "TRON" },
  "amount":  "0.05"
}

Ответ 200

{
  "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,
    "min": "0.0007",
    "max": null
  }
}

Что можно показать своему клиенту

Ответ /quotes несёт executor: по умолчанию только факт, что исполнитель есть и его условия приняты. Имя, disclosure и passport (кто держит депозит, режимы курса, условия и наша история с этим провайдером) приходят там, где условия исполнителя требуют назвать его. comparedExecutors по умолчанию число: сколько исполнителей ответило на запрос. Показывать ли это пользователю, решаете вы. Имена исполнителей включаются на ключе, если вы действительно показываете сравнение.

Завершённые обмены по вашему ключу могут приносить вознаграждение. Если исполнитель удерживает наценку на ордере, вознаграждением за этот обмен будет эта наценка. Сумма ждёт подтверждения дохода, затем её можно вывести. Начисления и выплаты видны в кабинете партнёра и в /api/v1/balance. partnerMarkup всегда равен строке «0» и заработок не показывает.

Может вернуть

400 invalid_request400 invalid_amount422 unsupported_option401 unauthorized403 ip_not_allowed503 service_unavailable422 amount_below_minimum422 amount_above_maximum422 pair_unavailable503 pricing_unavailable429 rate_limited500 internal_error

Курсы пакетом

POST/api/v1/quotes/bulk

До 20 котировок одним вызовом, для витрин с курсами по многим парам. Каждый элемент считается ровно как POST /quotes и отвечает тем же телом: quote или error, плюс status — HTTP-статус, который дал бы одиночный запрос. Ответы идут в порядке элементов, index — номер элемента. Плохой элемент не ломает пакет: его ошибка приходит в его ответе. Элементы считаются параллельно; на весь пакет не больше 8 секунд, опоздавшие элементы получают quote_timeout. Стоит 1 + ceil(n/5) запросов чтения: 20 элементов — 5. У ключа идёт не больше одного пакета за раз: второй, пока первый не закончен, получает 429 с Retry-After. Ошибки в списке ниже — ошибки всего запроса.

Доступ: Боевой ключ.

Запрос

{
  "items": [
    {
      "send":    { "asset": "BTC",  "network": "BTC" },
      "receive": { "asset": "USDT", "network": "TRON" },
      "amount":  "0.05"
    },
    {
      "send":    { "asset": "ETH",  "network": "ETH" },
      "receive": { "asset": "USDT", "network": "TRON" },
      "amount":  "0.0001"
    }
  ]
}

Ответ 200

{
  "results": [
    {
      "index": 0,
      "status": 200,
      "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,
        "min": "0.0007",
        "max": null
      }
    },
    {
      "index": 1,
      "status": 422,
      "error": {
        "code": "amount_below_minimum",
        "message": "The amount is below the minimum for this pair. The minimum is 0.004 ETH/ETH."
      }
    }
  ],
  "count": 2
}

Может вернуть

400 invalid_request401 unauthorized403 ip_not_allowed503 service_unavailable503 pricing_unavailable429 rate_limited500 internal_error

Список сделок

GET/api/v1/deals

Ваши сделки, новые сверху. Параметры: limit (по умолчанию 50, максимум 100) и cursor из nextCursor предыдущей страницы. partnerMarkup — наценка партнёра, записанная в котировке этой сделки, как есть; у сделок с отключённой наценкой это «0», и по нему нельзя определять доход. commissionUsd — проведённое начисление в USD. null означает, что начисления ещё нет, в том числе у завершённого обмена без подтверждения дохода. Это не нулевой заработок. Доступную к выводу сумму запрашивайте через /balance.

Доступ: Боевой ключ. Видны только сделки, созданные вашим ключом.

Ответ 200

{
  "deals": [
    {
      "id": "7f1c...",
      "status": "COMPLETED",
      "stage": "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"
}

Может вернуть

400 invalid_request401 unauthorized403 ip_not_allowed503 service_unavailable429 rate_limited500 internal_error

Создание обмена

POST/api/v1/deals

Выбирает исполнителя, пере-котирует у него и возвращает адрес депозита, на который платит ваш клиент. Курс из /quotes не принимается: он всегда считается заново. Только плавающий курс и сумма отправки: rateType и amountSide можно не передавать, другое значение даст unsupported_option. memo — строка; целое число (тег XRP) тоже принимается. refundMemo — memo адреса возврата, по тем же правилам; если сеть отправки требует memo или refundMemo передан, обмен уйдёт только к исполнителю, который принимает memo для возврата. Если адрес возврата принадлежит бирже или кастодиальному сервису, возврат без memo теряется: в этом случае всегда передавайте refundMemo. Повтор с тем же Idempotency-Key отвечает записанной сделкой без новой котировки, поэтому он не зависит от того, доступна ли пара сейчас. status и stage в ответе те же, что вернёт GET /deals/:id: у нового обмена это ACTIVE и AWAITING_DEPOSIT.

Доступ: Только боевой ключ. Обязателен заголовок Idempotency-Key.

Запрос

{
  "send":          { "asset": "BTC",  "network": "BTC" },
  "receive":       { "asset": "USDT", "network": "TRON" },
  "amount":        "0.05",
  "payoutAddress": "TJRabPrwbZy45sbavfcjinPJC18kjpRTv8",
  "refundAddress": "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4",
  "refundMemo":    null,
  "memo":          null,
  "campaignId":    null
}

Ответ 201

{
  "deal": {
    "id": "7f1c...",
    "status": "ACTIVE",
    "stage": "AWAITING_DEPOSIT",
    "send":    { "asset": "BTC",  "network": "BTC",  "amount": "0.05" },
    "receive": { "asset": "USDT", "network": "TRON", "amount": "3212.44" },
    "deposit": {
      "address":  "bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3",
      "tag":      null,
      "amount":   "0.05",
      "deadline": "2026-07-27T10:15:00Z"
    },
    "trackUrl": "https://<site>/track/7f1c..."
  }
}
202: на проверке, депозит не отправлять

Если результат создания неизвестен, API возвращает 202: status ACTIVE, stage UNDER_REVIEW, адреса депозита нет. Этот ответ не означает, что обмен создан. Не запрашивайте депозит у клиента: заявку проверит оператор.

Может вернуть

400 invalid_request400 invalid_amount400 idempotency_key_required409 idempotency_key_conflict503 pricing_unavailable503 service_unavailable503 quote_unavailable503 replay_executor_unavailable503 order_reservation_failed422 executor_refused_request401 unauthorized403 ip_not_allowed422 amount_below_minimum422 amount_above_maximum422 pair_unavailable422 invalid_payout_address422 invalid_refund_address422 invalid_campaign422 memo_required422 refund_memo_unsupported422 unsupported_option429 rate_limited500 internal_error

Статус

GET/api/v1/deals/:id

Текущее состояние вашей сделки, восстановленное из журнала событий. status - статус сделки, stage - шаг обмена (таблицы ниже). Чтение не пассивное: пока сделка идёт, перед ответом CORE спрашивает у исполнителя свежий статус и продвигает сделку, поэтому GET может вернуть статус новее, чем был до запроса (не больше шести раз в минуту на сделку). Пока идёт ожидание депозита, ответ повторяет реквизиты депозита.

Доступ: Только боевой ключ. Видны только сделки, созданные вашим ключом.

Ответ 200

{
  "deal": {
    "id": "7f1c...",
    "status": "ACTIVE",
    "stage": "AWAITING_DEPOSIT",
    "send":    { "asset": "BTC",  "network": "BTC",  "amount": "0.05" },
    "receive": { "asset": "USDT", "network": "TRON", "amount": "3212.44", "final": false },
    "deposit": {
      "address": "bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3",
      "tag": null,
      "amount": "0.05",
      "deadline": "2026-08-24T09:42:44Z"
    },
    "trackUrl": "https://<site>/track/7f1c..."
  }
}

trackUrl в ответе это публичная страница сделки. Её можно отдать клиенту: аккаунт не нужен, там видно статус, хеши и форма обращения.

Может вернуть

401 unauthorized403 ip_not_allowed503 service_unavailable404 not_found429 rate_limited500 internal_error

Статусы сделки

status - статус сделки: идёт она или чем закончилась. Создание, GET, список и вебхуки отдают его одинаково.

DRAFTЗаведена, ордер у провайдера ещё не подтверждён.
ACTIVEИдёт: ждём депозит, подтверждения, обмен или выплату.
COMPLETEDВыплата отправлена клиенту. · конечный
FAILEDОбмен не состоялся и возврат не проводился. · конечный
REFUNDEDПровайдер вернул монеты на адрес возврата. · конечный
EXPIREDДепозит не пришёл в срок. · конечный

Шаг обмена

stage - шаг внутри сделки, его и показывайте клиенту. Приходит рядом со status везде, где есть сделка.

AWAITING_DEPOSITЖдём депозит. Реквизиты в deposit.
CONFIRMINGДепозит виден, сеть его подтверждает.
EXCHANGINGДепозит подтверждён, идёт обмен.
SENDINGВыплата отправляется клиенту.
COMPLETEDВыплата отправлена. · конечный
FAILEDОбмен не состоялся. · конечный
REFUNDEDДепозит вернули на адрес возврата. · конечный
EXPIREDДепозит не пришёл в срок. · конечный
ACTION_REQUIREDОбмен остановлен из-за проблемы с депозитом или ордером, например недоплаты. Её решает поддержка вместе с клиентом; отправьте клиента на trackUrl.
UNDER_REVIEWОрдер у исполнителя не подтверждён, адреса депозита нет. Ничего не отправляйте: заявку проверяет оператор.

Смена статуса и шага приходит вебхуком, если он настроен: deal.status_changed и deal.stage_changed. Источник истины - GET /api/v1/deals/:id: без вебхука опрашивайте его раз в 15-30 секунд и останавливайтесь на конечном статусе.

Вебхуки

Вебхук задаётся на API-ключ в кабинете: один https-адрес на порту 443 на ключ. При сохранении показывается секрет подписи, один раз. Там же тестовое событие, новый секрет и последние доставки с кодом ответа. Получить ключ

События

deal.status_changed Сделка, созданная этим ключом, перешла в другой статус. previousStatus и status описывают этот переход. deal - тот же объект, что возвращает GET /api/v1/deals/:id, со stage, на момент первой попытки доставки. Шаги внутри ACTIVE приходят событием deal.stage_changed.

{
  "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",
      "stage": "COMPLETED",
      "trackUrl": "https://<site>/track/7f1c...",
      "send":    { "asset": "BTC",  "network": "BTC",  "amount": "0.05" },
      "receive": { "asset": "USDT", "network": "TRON", "amount": "3180.31", "final": true }
    }
  }
}

deal.stage_changed У сделки, созданной этим ключом, сменился stage: депозит виден, идёт обмен, выплата отправляется и так далее. previousStage и stage описывают этот шаг. Одна смена - одно событие; переходы, которые stage не меняют, событий не дают. deal - тот же объект, что возвращает GET /api/v1/deals/:id, на момент первой попытки доставки. Конечный шаг приходит и этим событием, и deal.status_changed. Две смены шага в одной транзакции могут прийти в любом порядке: опирайтесь на previousStage или сверяйтесь с GET.

{
  "id": "4d2a7c90-1b3e-4f5a-8c6d-7e8f9a0b1c2d",
  "type": "deal.stage_changed",
  "createdAt": "2026-08-24T09:21:37.540Z",
  "data": {
    "previousStage": "AWAITING_DEPOSIT",
    "stage": "CONFIRMING",
    "deal": {
      "id": "7f1c...",
      "status": "ACTIVE",
      "stage": "CONFIRMING",
      "trackUrl": "https://<site>/track/7f1c...",
      "send":    { "asset": "BTC",  "network": "BTC",  "amount": "0.05" },
      "receive": { "asset": "USDT", "network": "TRON", "amount": "3212.44", "final": false }
    }
  }
}

reward.available Начисление по вашему аккаунту стало доступно к выводу. Только сумма в USD: ставки в событии нет. Приходит на каждый вебхук аккаунта. Сумма в примере условная.

{
  "id": "5e0a9d44-7b1c-4f3e-8a26-c91d0f6b2e77",
  "type": "reward.available",
  "createdAt": "2026-08-31T00:01:10.502Z",
  "data": {
    "reward": { "id": "c2d4e6f8-1a3b-4c5d-8e7f-9a0b1c2d3e4f", "amount": "1.25", "currency": "USD" }
  }
}

webhook.test Отправляется кнопкой в кабинете. Подписано так же, как настоящие события.

{
  "id": "9a7e2c11-3f64-4d0b-b5a8-0e2c6f1d4b93",
  "type": "webhook.test",
  "createdAt": "2026-08-24T09:12:44.000Z",
  "data": {}
}

Подпись

Каждый запрос несёт заголовок X-Core-Signature: t=<unix-секунды>,v1=<hex>. v1 - HMAC-SHA256 от строки «t.тело» с вашим секретом. Проверяйте подпись по сырому телу запроса до разбора JSON и отклоняйте t старше 300 секунд.

Node.js

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);
}

Python

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

<?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);
}

Доставка

Доставлено - это любой ответ 2xx за 5 секунд. Редиректы не выполняются и считаются ошибкой. Повторы через: 30 с, 2 мин, 10 мин, 30 мин, 1 ч, 3 ч, 6 ч, 12 ч, после этого доставка прекращается. Одно событие может прийти больше одного раза и не по порядку: храните id обработанных событий (он же заголовок X-Core-Event-Id, свой у каждого вебхука и не меняется при повторах) и сверяйте статус через GET. Адрес должен быть публичным: во внутренние сети и на адреса метаданных запросы не уходят.

Идемпотентность

Заголовок Idempotency-Key обязателен при создании обмена. Без него API возвращает 400. Повтор с тем же ключом возвращает исходную сделку, не создавая новую и не запрашивая курс заново: ответ не зависит от того, доступна ли пара сейчас.

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":"TJRabPrwbZy45sbavfcjinPJC18kjpRTv8",
       "refundAddress":"bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"}'

Ключ генерируется на попытку обмена, а не на HTTP-запрос. Повторяйте с тем же ключом, пока не получите окончательный ответ. Формат: от 8 до 128 символов, латинские буквы, цифры и . _ : - , первый символ буква или цифра; UUID подходит.

Ошибки

HTTPcode
400invalid_requestТело не JSON; нет обязательного поля (send, receive, amount, payoutAddress, refundAddress, а для /validate-address — asset, network, address); memo или refundMemo не строка и не целое неотрицательное число, refundMemo длиннее 256 символов; refundMemo на сети отправки без memo; Idempotency-Key не по формату (8–128 символов: латинские буквы, цифры и . _ : -, первый символ буква или цифра); limit или cursor в GET /deals не подходят; send в GET /pairs не вида АКТИВ:СЕТЬ; items в /quotes/bulk не массив от 1 до 20 элементов. В /quotes/bulk ошибка одного элемента приходит в его ответе, а не во всём запросе.
400invalid_amountamount не строка положительного десятичного числа.
400idempotency_key_requiredНа POST /deals нет заголовка Idempotency-Key. Без него повтор создал бы второй обмен.
401unauthorizedНет заголовка Authorization, либо ключ неверен или отозван.
403ip_not_allowedУ ключа задан список разрешённых IP, и запрос пришёл с другого адреса. Список меняется в кабинете, в разделе API.
404not_foundСделки с таким id нет среди созданных вашим ключом. Чужая сделка тоже даёт 404: существование не раскрывается. Тот же код на путь /api/v1/..., которого нет.
405method_not_allowedМетод не подходит к эндпоинту, например DELETE /api/v1/quotes. Заголовок Allow перечисляет допустимые.
409idempotency_key_conflictТот же Idempotency-Key на другой обмен: другая пара, сумма или адрес, в том числе другой refundAddress или refundMemo. Ключ генерируется на попытку обмена и повторяется только для ретраев этой же попытки. С миграции 085 это НЕ срабатывает из-за того, что сравнение при повторе выбрало другого исполнителя.
503pricing_unavailableНе удалось проверить настройки комиссии. Обмен не создаётся с неподтверждёнными настройками. Повторите запрос позже; для создания сохраните тот же Idempotency-Key.
503service_unavailableНе удалось прочитать наши собственные данные: ключ API на любом запросе, реестр активов на /validate-address, реестр активов, кампанию или уже созданный ордер на POST /deals. Ничего нового не создано, и в лимит неудачных попыток с IP это не входит. Повторите позже; для создания сохраните тот же Idempotency-Key.
503quote_timeoutТолько в ответе элемента /quotes/bulk: исполнители не ответили по этому элементу за 8 секунд. Остальные элементы от этого не страдают. Повторите этот элемент.
503quote_unavailableНе удалось обновить котировку перед созданием обмена. Провайдеру не отправляли запрос на создание. Повторите с тем же Idempotency-Key.
503replay_executor_unavailableПовтор с тем же Idempotency-Key: обмен уже создан, но исполнителя, который его ведёт, сейчас не поднять. Повторите с ТЕМ ЖЕ ключом чуть позже. Новый ключ создаст второй настоящий ордер.
503order_reservation_failedОбмен не удалось подтвердить. Повторите с ТЕМ ЖЕ ключом: повтор никогда не создаёт второй ордер. Если ордер у провайдера всё же был создан, повтор вернёт его или обмен уйдёт на проверку оператору.
422executor_refused_requestИсполнитель проверил заявку и отклонил её: неверный адрес, сумма вне диапазона, актив не поддерживается или котировка истекла. Ордера не существует, депозит не нужен. Исправьте заявку и отправьте с НОВЫМ Idempotency-Key. Раньше это возвращалось как 202 «на проверке, не отправляйте депозит», хотя ждать было нечего.
422unsupported_optionВ /quotes или /deals явно указан режим, которого нет: rateType (rate_type) не "float" или amountSide (amount_side) не "SOURCE". Фиксированный курс и точная сумма получения не поддерживаются. Раньше такие поля молча игнорировались: /deals создавал обмен по плавающему курсу, /quotes отдавал плавающий курс.
422amount_below_minimumСумма ниже минимума провайдера для этой пары. Увеличьте её.
422amount_above_maximumСумма выше максимума провайдера для этой пары. Уменьшите её. Раньше этот случай приходил как pair_unavailable, то есть неправдой.
422pair_unavailableНи один активный провайдер не котирует эту пару сейчас, либо инструмент выключен между котировкой и созданием.
422invalid_payout_addresspayoutAddress не похож на адрес сети получения. Проверка идёт до обращения к провайдеру.
422invalid_campaigncampaignId не является активной кампанией вашего аккаунта. Проверка идёт до обращения к провайдеру.
422invalid_refund_addressrefundAddress не похож на адрес сети отправки.
422memo_requiredСеть получения требует memo или тег, а поле memo не передано.
422refund_memo_unsupportedСеть отправки опознаёт получателя по memo, или передан refundMemo, а сейчас ни один исполнитель этой пары не принимает memo для адреса возврата. Или исполнитель, выбранный для обмена, перестал принимать memo для возврата до создания ордера: ордер не создан, повторите с НОВЫМ Idempotency-Key.
503balance_unavailableБаланс сейчас не читается. Это ошибка, а не ноль: ложный ноль на денежном эндпоинте хуже, чем 5xx.
429rate_limitedКлюч превысил лимит: 600 запросов в минуту на чтение (справочник, границы, проверка адреса, курсы, чтение сделок), 30 на GET /pairs или 60 на создание обменов и /balance, если для ключа не договорились о большем. Пакет /quotes/bulk стоит 1 + ceil(n/5) запросов чтения, и у ключа идёт не больше одного пакета за раз: второй, пока первый не закончен, получает 429. Или с одного IP за минуту пришло больше 120 запросов с неверным или отозванным ключом либо с адреса, которого нет в списке ключа; удачные запросы сюда не входят. Заголовок Retry-After говорит, через сколько секунд повторить.
500internal_errorНепредвиденная ошибка на нашей стороне, она записана. Повторите тот же запрос (для POST /deals — с тем же Idempotency-Key): повтор никогда не создаёт второй обмен.

Чего API не делает

Не поддерживаются: фиксированный курс, отмена созданного обмена, указание точной суммы получения, отдельная разбивка сетевых комиссий и тестовая среда. Активные провайдеры работают с плавающим курсом.

Страны и территории

CORE не обслуживает клиентов из следующих стран и территорий: Северная Корея, Иран, Сирия, Куба, Мьянма, Крым, Севастополь, Донецкая область, Луганская область, США, Пуэрто-Рико, Гуам, Виргинские острова США, Американское Самоа, Северные Марианские острова, Внешние малые острова США, Великобритания, Канада, Япония. API не проверяет страну по IP запроса: запросы приходят с вашего сервера. Не создавайте обмены для пользователей из этих стран и территорий.