Как подключить API криптошлюза: руководство для разработчика

API криптошлюза — HTTP-интерфейс, через который бэкенд создаёт счета, читает статус оплаты и отправляет выплаты без плагина между кодом и процессингом. Материал для разработчика, который уже выбрал приём криптовалюты и хочет рабочую интеграцию Speend, а не сравнение провайдеров. Вы получаете реальные эндпоинты, проверку подписи вебхука, модель статусов вебхука и выплаты, а каждый пример сверяется с плагином WooCommerce, где лежит тот же клиентский код.

Если приём криптовалюты ещё под вопросом, начните с гайда как принимать криптовалюту. Здесь разбор идёт после этого шага.

Плагин, размещённая страница, ссылка или API: что выбрать

Выбирайте по тому, сколько нужно контроля и сколько вы готовы писать сами. Четыре способа подключения, от самого готового к самому гибкому: готовый плагин, платёжная ссылка, динамический счёт через API и статические кошельки через API. К API переходите, когда у вас своя касса, свой бэкенд или платформа без плагина.

Готовый плагин — быстрый путь, если магазин работает на поддерживаемой платформе. Speend отдаёт готовые плагины для WooCommerce, OpenCart, WHMCS, PrestaShop и XenForo. Плагин WooCommerce ставится меньше чем за два часа и сам синхронизирует статус заказа по вебхукам. Смотрите раздел плагинов и страницу плагина WooCommerce. Если вашей платформы среди готовых плагинов нет, остаётся API.

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

Динамический счёт через API — ядро интеграции. Бэкенд вызывает createPayment и получает свежий адрес address, QR-код и точную сумму payer_amount в валюте плательщика. Кассу рисуете сами, оплату подтверждаете по вебхукам. Полный контроль, без размещённой страницы в цепочке.

Статические кошельки через API дают один многоразовый адрес на клиента или цель через createStaticWallet, вместо нового счёта каждый раз. Оба варианта API работают host-to-host: касса ваша, сверка по вебхукам. На них построена остальная часть руководства, потому что в выдаче эту схему объясняют хуже всего.

Вопрос api vs plugin сводится к одной строке. Берите плагин, когда он есть под ваш стек и настройки по умолчанию подходят. Берите API, когда нет. Одиночному магазину на WooCommerce с фиксированным каталогом API чаще всего не нужен, а сборка своей интеграции там, где плагин уже закрывает задачу, добавляет лишний код на сопровождение без выгоды. API оправдывает себя, когда касса ваша, цена считается динамически, маркетплейс делит поступления между получателями или стек не покрыт ни одним плагином.

Что нужно до первого запроса

Нужны хост, два ключа и песочница. Базовый хост — https://api.speend.io, каждый вызов идёт методом POST с телом JSON, и каждый запрос несёт два заголовка: merchant (ваш Merchant UUID) и key (ваш API-ключ). Это набор, который платёжный API ждёт до первого вызова.

Два ключа, не один, и оба идут в одном заголовке key. Основной API-ключ авторизует платёжные вызовы, а выплаты и возвраты берут отдельный ключ вывода из дашборда, в том же заголовке key. Оба ключа, Merchant UUID и опциональный пароль вебхука лежат в дашборде мерчанта. Домен магазина должен быть зарегистрирован у Speend, иначе аккаунт не примет живой трафик.

Песочница повторяет продакшен один в один, поэтому вы собираете и проверяете интеграцию на тех же формах запроса и ответа, что увидите в бою. Неверные ключи возвращают {"error":"No access"}, и это быстрый способ убедиться, что заголовки подключены правильно. Ошибки валидации приходят с HTTP 422 и телом {"status": false, "errors": [...]}: ориентируйтесь на код ответа, тело разбирайте вторым шагом.

Держите ключи песочницы и продакшена в разной конфигурации, не в коде, и меняйте их из дашборда при утечке. Задайте пароль вебхука, даже если он опциональный: это вторая половина проверки подписи из следующего раздела, без неё проверка держится на одном API-ключе. Магазин без пароля работает, но отдаёт слой защиты даром.

Создание платежа

Вызовите POST /payment/createPayment с order_id, amount и currency. В ответ придут uuid, ваш order_id, адрес address, address_qr_code и сумма payer_amount, которую плательщик отправляет в payer_currency. order_id должен быть уникальным среди ваших счетов, статических кошельков и выплат: повтор с тем же order_id отклоняется с 422 и ошибкой order_id is not unique. Отрисуйте адрес и QR в своей кассе, оплату подтверждайте по вебхукам.

Вот счёт в биткоине. Та же форма crypto payment API работает для любого поддерживаемого актива, а цену в фиате вы задаёте полем currency с фиатным кодом плюс to_currency для расчёта.

curl -X POST https://api.speend.io/payment/createPayment \
  -H "merchant: <MERCHANT_UUID>" \
  -H "key: <API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{"order_id": "order-1042", "amount": "0.005", "currency": "BTC", "lifetime": 3600, "url_callback": "https://shop.example/webhooks/speend"}'
$body = json_encode([
    'order_id'     => 'order-1042',
    'amount'       => '0.005',
    'currency'     => 'BTC',
    'lifetime'     => 3600,
    'url_callback' => 'https://shop.example/webhooks/speend',
], JSON_UNESCAPED_UNICODE);

$ch = curl_init('https://api.speend.io/payment/createPayment');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => ['merchant: '.$merchantUuid, 'key: '.$apiKey, 'Content-Type: application/json'],
    CURLOPT_POSTFIELDS     => $body,
    CURLOPT_RETURNTRANSFER => true,
]);
$payment = json_decode(curl_exec($ch), true);
// render $payment['address'] and $payment['address_qr_code'] in your checkout
import requests

resp = requests.post(
    "https://api.speend.io/payment/createPayment",
    headers={"merchant": MERCHANT_UUID, "key": API_KEY},
    json={
        "order_id": "order-1042",
        "amount": "0.005",
        "currency": "BTC",
        "lifetime": 3600,
        "url_callback": "https://shop.example/webhooks/speend",
    },
)
payment = resp.json()
# покажите payment["address"] и payment["address_qr_code"]
const resp = await fetch("https://api.speend.io/payment/createPayment", {
  method: "POST",
  headers: {
    merchant: MERCHANT_UUID,
    key: API_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    order_id: "order-1042",
    amount: "0.005",
    currency: "BTC",
    lifetime: 3600,
    url_callback: "https://shop.example/webhooks/speend",
  }),
});
const payment = await resp.json();
// покажите payment.address и payment.address_qr_code

В ответе приходят адрес для показа и сумма к оплате:

{
  "uuid": "019ae4ef-…",
  "order_id": "order-1042",
  "amount": 0.005,
  "payer_amount": 0.005,
  "payer_currency": "BTC",
  "currency": "BTC",
  "address": "bc1q…",
  "address_qr_code": "iVBORw0KGgo…"
}

order_id — это и идентификатор заказа, и ключ уникальности: строка из букв, цифр, подчёркиваний и дефисов, без пробелов, уникальная на заказ и неизменная между повторами. Полем lifetime вы задаёте срок жизни счёта в секундах, например 3600 для одного часа. В url_callback укажите адрес, куда придут вебхуки. Чтобы ограничить или заранее выбрать активы, передайте currencies или except_currencies, каждый как список пар {currency, network}, либо одну network. Для bitcoin payment API оставленное пустым поле network позволяет плательщику выбрать сеть самому.

Несколько опциональных полей отвечают за расчёт и сверку. Полем to_currency вы задаёте валюту расчёта, всегда код криптовалюты: цену выставляете в фиатной currency, а расчёт получаете, например, в USDT. Полем subtract вы задаёте процент платёжной комиссии, который берёте с клиента, от 0 до 100. При 100 клиент оплачивает всю комиссию поверх суммы. Поле additional_data передаётся вместе со счётом и возвращается при чтении статуса и в вебхуках, что связывает платёж с заказом в вашей системе без отдельной таблицы соответствий. Полем accuracy_payment_percent вы задаёте допуск на недоплату в процентах, максимум 5. При 5 счёт считается оплаченным, когда клиент прислал не меньше 95%.

Как узнать, что платёж прошёл

Читайте статус двумя путями: POST /payment/info по запросу и вебхук на ваш url_callback. Действуете по вебхуку: платёж завершён только при status: 2 с is_final. Не помечайте заказ выполненным по одному факту создания счёта.

curl -X POST https://api.speend.io/payment/info \
  -H "merchant: <MERCHANT_UUID>" \
  -H "key: <API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{"order_id": "order-1042"}'

В вебхуке приходит авторитетный статус, и он числовой: 1 создан, 2 оплачен, 3 отменён. Рядом идёт is_final — этот флаг означает, что счёт закрыт и уже не изменится, поэтому выполняйте заказ только при status: 2 и is_final: true.

ПолеЗначениеЧто делаете
status: 1Создан, депозита нетЗаказ ждёт
status: 2ОплаченВыполняете, когда is_final истинно
status: 3Отменён или истёк без оплатыСнимаете резерв, закрываете заказ
is_finalСчёт закрыт, изменений не будетОстанавливаете опрос

В вебхуке, кроме статуса, приходят суммы для сверки: amount и amount_usd по счёту, merchant_amount со списанной commission, адрес плательщика from, network, currency и ончейн-хэш транзакции как txid. По полю type видно, откуда платёж: gateway-счёт (2) или статический кошелёк (1). Депозит не финален в момент, когда его заметили, поэтому дождитесь is_final, прежде чем двигать заказ. Глубина подтверждений зависит от актива.

Вебхуки без дыр

Проверяйте каждый вебхук до того, как на него реагировать, и не доверяйте телу дальше идентификаторов. Подпись — это MD5 по Base64-копии полезной нагрузки без поля sign, склеенной с вашим API-ключом и паролем вебхука. Больше всего интеграций спотыкается на порядке ключей. Подпись считается по JSON ровно в том виде, как он пришёл, поэтому разбирайте сырое тело с сохранением порядка и кодируйте обратно тем же компактным способом, без экранирования Unicode.

$raw     = file_get_contents('php://input');
$payload = json_decode($raw, true);            // сохраняет порядок ключей
$received = $payload['sign'] ?? '';
unset($payload['sign']);

$expected = md5(
    base64_encode(json_encode($payload, JSON_UNESCAPED_UNICODE))
    . $apiKey . $webhookPassword                // '' если пароль вебхука не задан
);

if (!hash_equals($expected, $received)) {
    http_response_code(400);
    exit;
}
import base64, hashlib, hmac, json

raw     = request.get_data()                    # точные байты, как пришли
payload = json.loads(raw)
received = payload.pop("sign", "")

encoded  = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
expected = hashlib.md5(
    (base64.b64encode(encoded.encode()).decode() + api_key + webhook_password).encode()
).hexdigest()

if not hmac.compare_digest(expected, received):
    abort(400)
import crypto from "node:crypto";

const payload = JSON.parse(req.rawBody);        // разберите те же байты, что пришли
const received = payload.sign;
delete payload.sign;

const encoded = JSON.stringify(payload);        // сохраняет порядок ключей
const expected = crypto
  .createHash("md5")
  .update(Buffer.from(encoded).toString("base64") + apiKey + webhookPassword)
  .digest("hex");

const ok =
  received.length === expected.length &&
  crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
if (!ok) return res.status(400).end();

Чтобы рабочий обработчик стал корректным, добавьте три правила. Сравнивайте в постоянном времени, чтобы утечка по времени побайтно не помогла атакующему подобрать подпись. Перезапрашивайте статус через /payment/info после проверки. Берите из вебхука только uuid и order_id, а авторитетный статус читайте из API, чтобы заказ не сдвинулся от повтора или искажённого тела. И дедуплицируйте по последнему применённому статусу: Speend повторяет вебхук, и устаревший status: 3, пришедший после 2, нужно игнорировать.

Хотите принимать криптоплатежи на своём сайте?

Быстрый KYC/KYB и подключение - комиссия от 0,5%

Связаться с нами

Частичная оплата, переплата и просрочка

Обрабатывайте деньги, не набор статусов: отдельных кодов под недоплату и переплату API не шлёт. Три случая всё же требуют явных веток.

Короткий платёж регулируется полем accuracy_payment_percent. В пределах допуска, до 5%, счёт всё равно переходит в status: 2, а фактически полученная сумма зачисляется в merchant_amount. За пределами допуска счёт остаётся открытым, пока плательщик не доплатит или не истечёт lifetime, поэтому сверяйтесь по merchant_amount, не по запрошенному amount.

Переплата тоже закрывается как status: 2. Излишек виден как merchant_amount больше суммы счёта amount, поэтому сравните их и верните или зачтите разницу по своей политике.

Просрочка или отмена приходит как status: 3, когда lifetime истёк без полной оплаты. Снимите резерв товара и закройте заказ. Вебхуки повторяются, поэтому устаревшая 3 может прийти после 2 — правило дедупликации её отбрасывает.

Уникальные order_id и безопасные повторы

order_id должен быть уникальным среди ваших счетов, статических кошельков и выплат. Второй вызов с уже использованным order_id не вернёт исходный объект, а отклонится с 422 и ошибкой order_id is not unique. Эта уникальность и защищает от двойной оплаты, но из-за неё наивный повтор после таймаута может упасть, хотя всё в порядке.

ВызовКлючВторой вызов с тем же значением
/payment/createPaymentorder_id422, order_id is not unique
/payout/createorder_id422, order_id is not unique

Поэтому таймаут обрабатывайте осознанно. Когда создающий вызов отвалился по таймауту, вы не знаете, прошёл он или нет. Сначала найдите объект по order_id (для платежа /payment/info, для выплаты /payout/getStatus), и если он есть, исходный вызов удался. Повторяйте, только если по нему ничего не нашлось, и повторяйте с тем же order_id. Генерируйте этот идентификатор один раз на бизнес-событие и сохраняйте до вызова, не из таймстампа или случайного значения в момент запроса.

Массовые выплаты

Отправляйте пакет выплат вызовом POST /payout/create на каждого получателя с ключом вывода. С уникальными order_id пакет можно безопасно возобновить. Эндпоинт выплат один, поэтому массовый прогон — это цикл, а поскольку дубль order_id отклоняется с 422, сбой посреди пакета не приведёт к двойной оплате. При возобновлении проверьте /payout/getStatus по каждому order_id и отправьте только тех получателей, по кому записи ещё нет. Так выглядит паттерн api mass payments. Для криптовалюты на фиатных страницах выдачи его нет.

curl -X POST https://api.speend.io/payout/create \
  -H "merchant: <MERCHANT_UUID>" \
  -H "key: <WITHDRAWAL_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{"order_id": "payout-7781", "currency": "USDT", "network": "tron", "address": "T...", "amount": "250.00", "url_callback": "https://shop.example/webhooks/payout"}'
$body = json_encode([
    'order_id'     => 'payout-7781',
    'currency'     => 'USDT',
    'network'      => 'tron',
    'address'      => 'T...',
    'amount'       => '250.00',
    'url_callback' => 'https://shop.example/webhooks/payout',
], JSON_UNESCAPED_UNICODE);

$ch = curl_init('https://api.speend.io/payout/create');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => ['merchant: '.$merchantUuid, 'key: '.$withdrawalKey, 'Content-Type: application/json'],
    CURLOPT_POSTFIELDS     => $body,
    CURLOPT_RETURNTRANSFER => true,
]);
$payout = json_decode(curl_exec($ch), true);
import requests

for r in recipients:
    requests.post(
        "https://api.speend.io/payout/create",
        headers={"merchant": MERCHANT_UUID, "key": WITHDRAWAL_KEY},
        json={
            "order_id": r["order_id"],   # уникальный и стабильный на получателя
            "currency": "USDT",
            "network": "tron",
            "address": r["address"],
            "amount": r["amount"],
            "url_callback": "https://shop.example/webhooks/payout",
        },
    )
for (const r of recipients) {
  await fetch("https://api.speend.io/payout/create", {
    method: "POST",
    headers: {
      merchant: MERCHANT_UUID,
      key: WITHDRAWAL_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      order_id: r.order_id,          // уникальный и стабильный на получателя
      currency: "USDT",
      network: "tron",
      address: r.address,
      amount: r.amount,
      url_callback: "https://shop.example/webhooks/payout",
    }),
  });
}

В ответе /payout/create приходят uuid, order_id, address, amount, currency, network, status со значением REQUESTED и created_at. Опрашивайте /payout/getStatus по order_id или читайте колбэк: когда отправка проходит, status становится SUCCESS, а в объекте transaction_data приходят ончейн-txid, адреса from и to и сумма. 422 с insufficient balance означает, что выплата не создана.

С каждой выплаты берётся комиссия сети — по стоимости блокчейна, без наценки сверху, поэтому от выбранной сети зависит стоимость отправки пакета. Выбирайте network под получателя с учётом этого и не отправляйте все выплаты по одной сети, а текущую стоимость измеряйте в момент отправки: ончейн-комиссии двигаются, и число, вшитое в логику в прошлом квартале, сегодня не совпадёт.

Многоразовые адреса и статические кошельки

Вызывайте POST /payment/createStaticWallet, когда нужен один постоянный адрес, на который клиент платит не раз, вместо нового счёта каждый раз. Подходит для донатов, адресов пополнения на клиента и пополнения баланса. Передайте wallet_id, currency, network и url_callback. В ответ придут uuid, address и qr_base64_png для отрисовки. На каждое поступление на адрес приходит вебхук, поэтому сверка та же, что по счетам.

curl -X POST https://api.speend.io/payment/createStaticWallet \
  -H "merchant: <MERCHANT_UUID>" \
  -H "key: <API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{"wallet_id": 1, "currency": "TRX", "network": "TRON", "url_callback": "https://shop.example/webhooks/speend"}'

wallet_id подчиняется тому же правилу, что и order_id: уникален на мерчанта, повтор возвращает 422. Статический адрес открыт бессрочно, поэтому каждый вебхук считайте отдельным поступлением, не разовым закрытием счёта.

Тестирование перед боем

Прогоните весь поток сначала в песочнице, потому что в продакшене ломается именно обработка отказов, счастливый путь проходит и так. Песочница повторяет продакшен один в один, поэтому формы запроса и ответа, которые вы отрабатываете, — те же, что пойдут в бой.

Закройте пять путей до смены ключей. Убедитесь, что проверка подписи проходит на валидном вебхуке и отклоняет подделанный. Пройдите состояния: чистый status: 2, короткий платёж в пределах и за пределами accuracy_payment_percent, просрочку status: 3, и проверьте, что is_final открывает выполнение. Повторите createPayment и /payout/create с тем же order_id и убедитесь в 422. Отправьте один и тот же вебхук дважды и убедитесь, что дедупликация отбрасывает повтор. Наконец, вызовите повтор вебхука и убедитесь, что обработчик остаётся согласованным на повторе.

Частые вопросы

Чем API отличается от плагина и когда нужен именно API?
Плагин — готовая интеграция для WooCommerce, которая берёт на себя кассу, синхронизацию статусов и вебхуки. API — тот же процессинг без слоя CMS: эндпоинты вы вызываете сами. API нужен, когда у вас своя касса или свой бэкенд, либо когда под вашу платформу плагина нет.

Как проверить, что вебхук настоящий?
Пересчитайте подпись: MD5 от Base64 JSON полезной нагрузки без sign, склеенного с вашим API-ключом и паролем вебхука. Разберите сырое тело с сохранением порядка ключей, сравните в постоянном времени и отклоните при несовпадении. Затем перезапросите /payment/info и действуйте по возвращённому статусу, не по телу.

Что при недоплате и переплате?
Отдельного статуса у них нет. Короткий платёж в пределах accuracy_payment_percent всё равно закрывается как status: 2; за пределами счёт остаётся открытым, пока не доплатят или не истечёт срок. Переплата тоже закрывается как status: 2, а излишек виден как merchant_amount больше суммы счёта amount, поэтому сверяйтесь по merchant_amount.

Можно ли повторить создание платежа с тем же order_id?
Не вслепую. order_id должен быть уникальным, поэтому дубль возвращает 422 с order_id is not unique вместо исходного счёта. После таймаута сначала найдите заказ через /payment/info: если он есть, первый вызов удался. Если нет, повторите с тем же order_id.

Есть ли готовые SDK?
Отдельного публичного SDK для установки нет. PHP-клиент поставляется внутри плагина WooCommerce, который можно скачать и читать как эталонную реализацию каждого вызова из руководства.

Как устроены массовые выплаты?
Цикл по получателям через /payout/create с ключом вывода. order_id уникален, поэтому повторный прогон не приведёт к двойной оплате: по уже отправленной выплате вернётся 422, так что при возобновлении сверьтесь с /payout/getStatus и отправьте только недостающих.

Куда двигаться дальше

Начните в песочнице и соберите четыре вызова, на которых держится интеграция: создать платёж, прочитать статус, проверить вебхук, отправить выплату. Проверьте каждый путь статуса до того, как направить что-либо в продакшен, и держите плагин WooCommerce открытым как эталон — это тот же процессинг, и каждый вызов выше лежит в его исходниках.

Поделиться
Anna Kuznetsova
Автор

Эксперт в области финтеха и криптовалют, специализирующийся на цифровых платежах, блокчейн-инфраструктуре, стейблкоинах и практическом использовании криптовалютных решений в бизнесе. Она анализирует отраслевые тренды, изменения в регулировании, платёжные технологии и способы интеграции цифровых активов в финансовые процессы компаний. В своих материалах Анна объясняет сложные темы понятным и практичным языком.