

Приём криптовалюты на сайте со своим бэкендом подключается так же, как любой платёжный сервис: ваш сервер создаёт заказ через API, покупатель платит на странице шлюза, а шлюз сообщает серверу о результате вебхуком. Ни кошелька на сервере, ни своей ноды блокчейна для этого не нужно — нужны три куска кода: подпись запроса, создание заказа и обработчик вебхука. Ниже — код для чистого PHP, Laravel и Python на примере API CryptumPay.
Принимать криптовалюту на сайте можно двумя способами. Первый — свой кошелёк и своя нода: сервер сам выдаёт адреса, сам следит за блокчейном и сам считает, сколько пришло. Второй — платёжный шлюз, его ещё называют криптопроцессингом: всю работу с блокчейном он берёт на себя, а вашему серверу отдаёт обычный REST API, как у банковского эквайринга. Статья про второй путь: на нём серверу не нужно ни кошелька, ни ноды, ни слежения за блокчейном — только HTTP-запросы к API и один адрес для вебхука.
На чём бы вы ни писали — на PHP, на Laravel или на Python, — цикл одной оплаты выглядит одинаково:
На третьем и четвёртом шаге ошибаются чаще всего: товар выдают по самому факту прихода вебхука или по возврату покупателя на страницу «спасибо». Почему так делать нельзя — ниже, в разделе про вебхук. А если шлюз ещё не выбран, до подключения стоит проверить его API по чек-листу для криптоплатежей.
В примерах ниже используется API CryptumPay, и вот что о нём нужно знать до первой строки кода:
Код ниже заработает, только если на стороне сервиса и на вашем сервере готовы четыре вещи:
Подпись нужна шлюзу, чтобы убедиться в двух вещах: запрос прислали вы, и по дороге его никто не изменил. Для этого к каждому запросу прикладывается отпечаток, который можно вычислить, только зная секрет. Проще говоря, это сургучная печать на конверте: содержимое видно всем, а поставить печать может только владелец штампа. Сама подпись считается в пять шагов, и шаги одинаковы для PHP и Python:
Подпись уходит в заголовке x-signature, вместе с ней — x-api-key с ключом, x-timestamp с тем же временем в миллисекундах и Content-Type: application/json.

Главное правило здесь одно: отправить ровно ту строку, которую подписали. PHP и Python по умолчанию пишут JSON иначе, чем JavaScript: PHP экранирует косую черту и кириллицу, Python ставит пробелы после запятых. Если подписать одну строку, а библиотека HTTP отправит другую, шлюз посчитает хеш от того, что пришло, и подпись не сойдётся. Поэтому в примерах JSON собирается вручную с нужными флагами и передаётся в запрос уже готовой строкой, а не массивом.
Для чистого PHP хватает расширения curl — оно должно быть включено на сервере — и встроенных функций hash и hash_hmac. Ниже — функция подписи и создание заказа.
<?php
// Ключ и секрет — из переменных окружения, не из кода
$apiKey = getenv('CRYPTUMPAY_API_KEY');
$apiSecret = getenv('CRYPTUMPAY_API_SECRET');
function cryptumpayHeaders(string $method, string $path, string $body, string $apiKey, string $apiSecret): array
{
$timestamp = (string) (int) round(microtime(true) * 1000); // Unix-время в миллисекундах
$bodyHash = hash('sha256', $body);
$prehash = $bodyHash . '|' . strtoupper($method) . '|' . $path . '|' . $timestamp . '|' . $apiKey;
$signature = hash_hmac('sha256', $prehash, $apiSecret);
return [
'Content-Type: application/json',
'x-api-key: ' . $apiKey,
'x-signature: ' . $signature,
'x-timestamp: ' . $timestamp,
];
}
$params = [
'title' => 'Заказ 1045',
'description' => 'Годовая подписка на сервис',
'fiatAmount' => '49.00',
'fiatCurrency' => 'usd',
'projectDataOrderId' => '1045',
];
// Компактный JSON без пробелов и экранирования — ровно та строка, которая уйдёт в запрос
$body = json_encode($params, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$path = '/v1/orders';
$ch = curl_init('https://papi.cryptumpay.com' . $path);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => cryptumpayHeaders('POST', $path, $body, $apiKey, $apiSecret),
CURLOPT_RETURNTRANSFER => true,
]);
$response = json_decode(curl_exec($ch), true);
curl_close($ch);
if ($response['errorObject'] !== null) {
// message у ошибки необязательное, appCode есть всегда
throw new RuntimeException('CryptumPay error ' . $response['errorObject']['appCode'] . ': ' . ($response['errorObject']['message'] ?? ''));
}
$orderId = $response['data']['id'];
// Сохраните пару «заказ 1045 в вашей базе — $orderId в CryptumPay»: по ней вы свяжете вебхук с заказом
$paymentUrl = 'https://pay.cryptumpay.com/m/' . $orderId;Что в полях заказа:
В ответе важны два поля:
Ссылку на оплату можно отдать покупателю как угодно — редиректом, письмом или сообщением. Если у вас не сайт, а бот, та же ссылка уходит сообщением в чат: так и устроен приём оплаты криптой в Телеграм-боте.
На Laravel логика та же, но ключи живут в конфигурации, а запросы делает HTTP-клиент фреймворка. Сначала конфигурация:
// config/services.php
'cryptumpay' => [
'key' => env('CRYPTUMPAY_API_KEY'),
'secret' => env('CRYPTUMPAY_API_SECRET'),
],Дальше — класс, который подписывает любой запрос. Обратите внимание на withBody: тело передаётся готовой строкой, а не массивом, иначе Laravel соберёт свой JSON и подпись не сойдётся.
<?php
// app/Services/CryptumPay.php
namespace App\Services;
use Illuminate\Support\Facades\Http;
class CryptumPay
{
private string $baseUrl = 'https://papi.cryptumpay.com';
private string $apiKey;
private string $apiSecret;
public function __construct()
{
// Ключи из config/services.php — так Laravel сможет внедрить класс в контроллер сам
$this->apiKey = config('services.cryptumpay.key');
$this->apiSecret = config('services.cryptumpay.secret');
}
public function request(string $method, string $path, array $params = []): array
{
// У POST подписывается тело запроса, у GET — строка {}; и та и другая отправляются телом
$body = $method === 'GET'
? '{}'
: json_encode($params, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$timestamp = (string) (int) round(microtime(true) * 1000);
$prehash = hash('sha256', $body) . '|' . $method . '|' . $path . '|' . $timestamp . '|' . $this->apiKey;
$pending = Http::withHeaders([
'Content-Type' => 'application/json',
'x-api-key' => $this->apiKey,
'x-signature' => hash_hmac('sha256', $prehash, $this->apiSecret),
'x-timestamp' => $timestamp,
]);
$pending = $pending->withBody($body, 'application/json');
$response = $method === 'GET'
? $pending->get($this->baseUrl . $path)
: $pending->post($this->baseUrl . $path);
$json = $response->json();
if ($json['errorObject'] !== null) {
// message у ошибки необязательное, appCode есть всегда
throw new \RuntimeException('CryptumPay error ' . $json['errorObject']['appCode'] . ': ' . ($json['errorObject']['message'] ?? ''));
}
return $json['data'];
}
public function createOrder(array $params): array
{
return $this->request('POST', '/v1/orders', $params);
}
public function order(string $orderId): array
{
return $this->request('GET', '/v1/orders/' . $orderId);
}
}В контроллере оформления заказа остаётся создать заказ, сохранить его номер и отправить покупателя платить:
$order = $cryptumpay->createOrder([
'title' => 'Заказ ' . $localOrder->id,
'description' => 'Годовая подписка на сервис',
'fiatAmount' => '49.00',
'fiatCurrency' => 'usd',
'projectDataOrderId' => (string) $localOrder->id,
]);
$localOrder->update(['cryptumpay_order_id' => $order['id']]);
return redirect()->away('https://pay.cryptumpay.com/m/' . $order['id']);Обработчик вебхука в Laravel — обычный контроллер, и у него две особенности. Маршрут стоит объявить в routes/api.php: там нет проверки CSRF, которая иначе отбросит POST-запрос шлюза. А сырое тело запроса берётся методом getContent у объекта Request — именно сырое, до разбора JSON. Сама проверка подписи такая же, как в примере на чистом PHP в разделе про вебхук ниже.
На Python хватит стандартных hashlib, hmac и json плюс библиотеки requests. Функция подписи и два запроса — создать заказ и узнать его статус:
import hashlib
import hmac
import json
import os
import time
import requests
API_KEY = os.environ["CRYPTUMPAY_API_KEY"]
API_SECRET = os.environ["CRYPTUMPAY_API_SECRET"]
BASE_URL = "https://papi.cryptumpay.com"
def signed_headers(method: str, path: str, body: str) -> dict:
timestamp = str(int(time.time() * 1000)) # Unix-время в миллисекундах
body_hash = hashlib.sha256(body.encode("utf-8")).hexdigest()
prehash = f"{body_hash}|{method.upper()}|{path}|{timestamp}|{API_KEY}"
signature = hmac.new(API_SECRET.encode("utf-8"), prehash.encode("utf-8"), hashlib.sha256).hexdigest()
return {
"Content-Type": "application/json",
"x-api-key": API_KEY,
"x-signature": signature,
"x-timestamp": timestamp,
}
def unwrap(response: requests.Response) -> dict:
payload = response.json()
if payload["errorObject"] is not None:
error = payload["errorObject"] # message необязательное, appCode есть всегда
raise RuntimeError(f"CryptumPay error {error['appCode']}: {error.get('message', '')}")
return payload["data"]
def create_order(params: dict) -> dict:
# Компактный JSON без пробелов, как JSON.stringify: подписываем и отправляем одну и ту же строку
body = json.dumps(params, separators=(",", ":"), ensure_ascii=False)
path = "/v1/orders"
response = requests.post(
BASE_URL + path,
data=body.encode("utf-8"),
headers=signed_headers("POST", path, body),
)
return unwrap(response)
def get_order(order_id: str) -> dict:
# У GET телом уходит строка {} — та же, что подписана
path = f"/v1/orders/{order_id}"
response = requests.get(BASE_URL + path, data=b"{}", headers=signed_headers("GET", path, "{}"))
return unwrap(response)
order = create_order({
"title": "Заказ 1045",
"description": "Годовая подписка на сервис",
"fiatAmount": "49.00",
"fiatCurrency": "usd",
"projectDataOrderId": "1045",
})
# Сохраните order["id"] рядом со своим заказом 1045
payment_url = f"https://pay.cryptumpay.com/m/{order['id']}"Важная строка здесь — json.dumps с параметром separators: без него Python вставит пробел после каждой запятой и двоеточия, и подписанная строка разойдётся с отправленной. Параметр ensure_ascii отключает замену каждой кириллической буквы на escape-код из шести символов — JSON.stringify такой замены не делает.
Вебхук — это POST-запрос, который шлюз присылает на ваш адрес при каждой смене статуса. Задача обработчика — убедиться, что запрос настоящий, запомнить его и быстро ответить. Выдавать товар прямо из обработчика, по содержимому вебхука, не стоит: подделать запрос на публичный адрес может кто угодно, а вебхуки могут приходить повторно.
Проверка вебхука у CryptumPay проще подписи запроса, но у неё свои правила:
Событий два типа. Вебхук merchantOrder приходит, когда заказ создан, и несёт ваши поля projectData вместе с номером заказа в CryptumPay. Вебхук customerOrder приходит на каждую смену статуса оплаты покупателя — created, pending, crediting, finished — и несёт номер заказа покупателя, merchantOrderId (тот самый номер вашего заказа в CryptumPay), cryptoCurrency, cryptoAmount и статус; сумма зачисления приходит только в finished.
Обработчик на чистом PHP:
<?php
$apiKey = getenv('CRYPTUMPAY_API_KEY');
$apiSecret = getenv('CRYPTUMPAY_API_SECRET');
$raw = file_get_contents('php://input'); // сырое тело — те самые байты, что пришли
$expected = hash_hmac('sha256', hash('sha256', $raw) . '|' . $apiKey, $apiSecret);
$received = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
if (!hash_equals($expected, $received)) {
http_response_code(400);
exit;
}
$eventId = $_SERVER['HTTP_X_IDMP_KEY'] ?? '';
// Если событие с таким $eventId уже обработано — ответить 200 и выйти
$event = json_decode($raw, true);
$merchantOrderId = $event['merchantOrderId'] ?? null;
if ($merchantOrderId !== null) {
// Статус проверяем запросом к API, а не по содержимому вебхука — см. следующий раздел
$status = getOrderStatus($merchantOrderId, $apiKey, $apiSecret);
if (in_array($status, [3, 4], true)) {
// Отметить заказ оплаченным и выдать товар
}
}
http_response_code(200);
// cryptumpayHeaders — функция из примера создания заказа выше
function getOrderStatus(string $merchantOrderId, string $apiKey, string $apiSecret): ?int
{
$path = '/v1/orders/' . $merchantOrderId;
$ch = curl_init('https://papi.cryptumpay.com' . $path);
curl_setopt_array($ch, [
// У GET телом уходит строка {} — та же, что подписана; метод задаём явно, иначе curl с телом отправит POST
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_POSTFIELDS => '{}',
CURLOPT_HTTPHEADER => cryptumpayHeaders('GET', $path, '{}', $apiKey, $apiSecret),
CURLOPT_RETURNTRANSFER => true,
]);
$response = json_decode(curl_exec($ch), true);
curl_close($ch);
if ($response['errorObject'] !== null) {
// message у ошибки необязательное, appCode есть всегда
throw new RuntimeException('CryptumPay error ' . $response['errorObject']['appCode'] . ': ' . ($response['errorObject']['message'] ?? ''));
}
// Блок financeSummary появляется, когда заказ покупателя дошёл до конечного состояния;
// пока его нет — заказ не оплачен
return isset($response['data']['financeSummary']['status'])
? (int) $response['data']['financeSummary']['status']
: null;
}Тот же обработчик на Flask:
# В том же файле, что и signed_headers с get_order из примера выше
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/webhooks/cryptumpay")
def cryptumpay_webhook():
raw = request.get_data() # сырые байты тела, до разбора JSON
body_hash = hashlib.sha256(raw).hexdigest()
expected = hmac.new(API_SECRET.encode("utf-8"), f"{body_hash}|{API_KEY}".encode("utf-8"), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers.get("X-Signature", "")):
abort(400)
event_id = request.headers.get("X-IDMP-Key")
# Если event_id уже встречался — вернуть 200 и ничего не делать повторно
event = json.loads(raw)
merchant_order_id = event.get("merchantOrderId")
if merchant_order_id:
order = get_order(merchant_order_id)
# Блока financeSummary нет, пока заказ покупателя не дошёл до конечного состояния
finance = order.get("financeSummary") or {}
if finance.get("status") in (3, 4):
pass # отметить заказ оплаченным и выдать товар
return "", 200В Django сырое тело лежит в request.body, в FastAPI его отдаёт await request.body() — остальное не меняется.
Обработчику выше для боевой нагрузки не хватает трёх вещей:
Как отвечать быстро, не делая всю работу в запросе, как защититься от двойной выдачи и что проверить до запуска, разобрано в статье про безопасную обработку вебхуков криптоплатежей. Про сервер, который лежал час, — в вопросах в конце этой статьи.
Истина о заказе живёт не в вебхуке и не в браузере покупателя, а в ответе API. GET-запрос /v1/orders с номером заказа возвращает customerOrderId — он появляется, когда покупатель начал оплату, — и блок financeSummary с полем status. Блок появляется, когда заказ покупателя дошёл до конечного состояния; пока его в ответе нет, заказ не оплачен, и код обязан это переживать. Статусов пять:
Товар по документации выдаётся при статусе 3 или 4. При 0 и 1 заказ выполнять нельзя, даже если покупатель уже пишет в поддержку, что всё отправил: до конца проверки оплата не считается состоявшейся.
Отдельно про возврат покупателя. После оплаты CryptumPay возвращает его на ваш домен по пути успеха или неудачи, который задан в проекте, и добавляет к адресу параметры cpayorder с номером заказа и cpayproject с номером проекта. Это удобно, чтобы показать нужную страницу, но доказательством оплаты не является: адрес видит и может набрать кто угодно. Страница «спасибо» показывает статус из вашей базы, а база обновляется только после проверки через API.
Когда заказов станет много, одной проверки статуса мало. Чтобы не потерять оплату, по которой вебхук не дошёл, свою базу нужно регулярно сверять с криптоплатежами по каждому заказу.
Когда интеграция не заводится, ответ API почти всегда говорит, где искать. У CryptumPay коды в поле appCode такие:
Если ошибка 7005 не уходит, выведите в лог строку тела, которую подписали, и строку, которую реально отправила библиотека, и сравните их глазами. Разница в одном пробеле или в экранированной косой черте — и это уже другой хеш. Полные описания ошибок и полей — в документации CryptumPay для разработчиков, она на английском.
Нужна ли вам серверная интеграция вообще, зависит только от того, кто задаёт сумму каждого заказа:
В двух последних случаях проверка оплаты всё равно никуда не девается: товар выдаётся только по статусу заказа, а не по сообщению покупателя о том, что он всё отправил.
Граница между виджетом и API проходит там, где сумму заказа должен задавать ваш код, а не покупатель; до этой границы задачу закрывает HTML-виджет для криптоплатежей вместо API и плагинов.
Приём криптовалюты на PHP, Laravel или Python — это три куска кода:
Товар выдаётся только по статусу 3 или 4 из GET-запроса к API, а не по вебхуку и не по возврату покупателя.
Нет. Любой шлюз с REST API подключается к PHP теми же средствами, что и к любому другому языку: расширение curl для запросов и встроенные функции hash и hash_hmac для подписи. Готовый SDK на PHP — удобство, а не условие: у CryptumPay алгоритм подписи расписан по шагам без привязки к языку, и вся интеграция из статьи умещается в пару сотен строк. Важнее, чтобы у шлюза были описаны алгоритм подписи и вебхуки: без них серверную интеграцию не собрать.
Схема та же: заказ, редирект, вебхук, проверка статуса. Разница в середине: у криптоплатежа есть промежуточные статусы — покупатель уже отправил перевод, а сеть его ещё подтверждает, — поэтому между «начал платить» и «оплачен» проходит заметное время, и код должен ждать финального статуса, а не первого вебхука. Второе отличие — перевод в сети нельзя отозвать, как списание с карты, поэтому возврат покупателю — это отдельный перевод в обратную сторону, а не отмена платежа.
CryptumPay повторит отправку: ответ не 2xx и редирект считаются неудачей, и уведомление приходит снова с нарастающим интервалом, до 12 попыток примерно за час. Если сервер не поднялся и за час, событие пропадёт, но не оплата: статус заказа всегда можно запросить GET-запросом по его номеру. Поэтому на вебхук стоит полагаться как на ускоритель, а не как на единственный источник правды.
Да. Настоящий заказ в CryptumPay создаётся сразу после регистрации, и оплата по нему примется; на баланс деньги зачислятся после проверки проекта. Так что вебхуки, статусы и выдачу товара можно отладить на реальном небольшом платеже, не дожидаясь проверки, а потом только дождаться зачисления.
Создайте аккаунт и подключите оплату самостоятельно, или свяжитесь с нами и мы поможем всё настроить