ru

Как подключить приём криптовалюты на сайте на PHP, Laravel или Python

Опубликовано
16.10.2026
Обновлено
17.10.2026
Разработчица за ноутбуком с открытым редактором кода, над экраном логотипы PHP, Laravel и Python, от ноутбука тянется светящаяся линия к монете USDT с зелёной галочкой на коробке заказа
Разработчица за ноутбуком с открытым редактором кода, над экраном логотипы PHP, Laravel и Python, от ноутбука тянется светящаяся линия к монете USDT с зелёной галочкой на коробке заказа
Contents

    Приём криптовалюты на сайте со своим бэкендом подключается так же, как любой платёжный сервис: ваш сервер создаёт заказ через API, покупатель платит на странице шлюза, а шлюз сообщает серверу о результате вебхуком. Ни кошелька на сервере, ни своей ноды блокчейна для этого не нужно — нужны три куска кода: подпись запроса, создание заказа и обработчик вебхука. Ниже — код для чистого PHP, Laravel и Python на примере API CryptumPay.

    Что делает платёжный шлюз, а что остаётся вашему серверу на PHP или Python

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

    На чём бы вы ни писали — на PHP, на Laravel или на Python, — цикл одной оплаты выглядит одинаково:

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

    На третьем и четвёртом шаге ошибаются чаще всего: товар выдают по самому факту прихода вебхука или по возврату покупателя на страницу «спасибо». Почему так делать нельзя — ниже, в разделе про вебхук. А если шлюз ещё не выбран, до подключения стоит проверить его API по чек-листу для криптоплатежей.

    Как это устроено у CryptumPay

    В примерах ниже используется API CryptumPay, и вот что о нём нужно знать до первой строки кода:

    • Отдельный домен API. Запросы уходят на papi.cryptumpay.com, адрес прописан в примерах. Ответ всегда приходит в обёртке из двух полей: data с результатом и errorObject. При ошибке data равно null, а в errorObject лежат httpCode, appCode и message.
    • Ключи выдаются в консоли. Путь: проект → Integration & API → Get API Keys. Секрет показывается один раз, при создании, и хранится вне кода.
    • Готовый SDK есть только для Node.js. Алгоритм подписи расписан по шагам без привязки к языку, поэтому сервер на PHP или Python вызывает REST API напрямую — так и сделано в примерах ниже.
    • Только HTTPS. Домены проекта и адрес вебхука должны работать по HTTPS; запрос с домена на HTTP отклоняется с ошибкой 3002.
    • Подключение бесплатное. CryptumPay берёт 1 % за успешный платёж, на больших объёмах — от 0,5 %.
    • Начать можно сразу. Настоящий заказ создаётся сразу после регистрации, до проверки проекта: платёж примется, а на баланс зачислится после того, как проект проверят.

    Что подготовить до первой строки кода

    Код ниже заработает, только если на стороне сервиса и на вашем сервере готовы четыре вещи:

    • Проект и ключи. Зарегистрируйтесь, создайте проект и получите ключ API с секретом в консоли CryptumPay. Ключ и секрет положите в переменные окружения — в примерах они называются CRYPTUMPAY_API_KEY и CRYPTUMPAY_API_SECRET — и не записывайте в код и в репозиторий.
    • Домен сайта на HTTPS. Домен проекта указывается в консоли, и без действующего сертификата запросы отклоняются.
    • Адрес вебхука. Это URL на вашем сервере, куда шлюз будет присылать уведомления. Он тоже задаётся в консоли на странице проекта и тоже должен быть на HTTPS с действительным сертификатом.
    • Точные часы на сервере. Подпись включает время запроса, и запрос старше одной минуты отклоняется. Если сервер отстаёт на пару минут, ни один запрос не пройдёт — синхронизируйте часы по NTP.

    Как подписать запрос к API платёжной системы

    Подпись нужна шлюзу, чтобы убедиться в двух вещах: запрос прислали вы, и по дороге его никто не изменил. Для этого к каждому запросу прикладывается отпечаток, который можно вычислить, только зная секрет. Проще говоря, это сургучная печать на конверте: содержимое видно всем, а поставить печать может только владелец штампа. Сама подпись считается в пять шагов, и шаги одинаковы для PHP и Python:

    1. Соберите тело запроса в компактный JSON. Без пробелов и переносов, без экранирования косой черты и кириллицы — так, как это делает JSON.stringify в JavaScript. У GET-запроса телом служит строка из двух фигурных скобок {}: она и подписывается, и отправляется — так делает официальный SDK.
    2. Посчитайте SHA-256 от этой строки и запишите в шестнадцатеричном виде строчными буквами. Это bodyHash.
    3. Возьмите путь запроса вместе с версией. Например, /v1/orders или /v1/orders/123. Путь начинается с косой черты, не заканчивается ею и не содержит домена.
    4. Сложите строку для подписи. Через вертикальную черту: bodyHash, метод заглавными буквами, путь, время запроса в миллисекундах Unix и ключ API. Получается bodyHash|POST|/v1/orders|timestamp|apiKey.
    5. Посчитайте HMAC-SHA256 от этой строки, где ключом служит секрет, и запишите в шестнадцатеричном виде. Это и есть подпись.

    Подпись уходит в заголовке x-signature, вместе с ней — x-api-key с ключом, x-timestamp с тем же временем в миллисекундах и Content-Type: application/json.

    Конверт с бирюзовой сургучной печатью со знаком USDT, рука прикладывает к ней латунный штамп с тем же знаком, рядом зелёная галочка совпадения, за конвертом ноутбук с редактором кода

    Главное правило здесь одно: отправить ровно ту строку, которую подписали. PHP и Python по умолчанию пишут JSON иначе, чем JavaScript: PHP экранирует косую черту и кириллицу, Python ставит пробелы после запятых. Если подписать одну строку, а библиотека HTTP отправит другую, шлюз посчитает хеш от того, что пришло, и подпись не сойдётся. Поэтому в примерах JSON собирается вручную с нужными флагами и передаётся в запрос уже готовой строкой, а не массивом.

    Приём криптовалюты на чистом PHP: создание заказа

    Для чистого PHP хватает расширения curl — оно должно быть включено на сервере — и встроенных функций hash и hash_hmac. Ниже — функция подписи и создание заказа.

    PHP
    <?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;

    Что в полях заказа:

    • title и description обязательны: их покупатель видит на странице оплаты, поэтому пишите по-человечески.
    • fiatAmount — сумма строкой с точкой, «49.00», а не число. Так сумма не потеряет копейки при разборе.
    • fiatCurrency — код валюты строчными буквами: usd, eur.
    • url — необязательный адрес, куда вернуть покупателя после оплаты; он перекрывает настройку проекта.
    • projectDataOrderId, projectDataUserId, projectDataUserEmail, projectDataMeta — ваши данные: номер заказа, пользователь, почта и произвольная строка, например JSON. Они вернутся вам в вебхуке о создании заказа.

    В ответе важны два поля:

    • data.id — номер заказа в CryptumPay, из которого собирается ссылка на оплату. Сохраните его рядом со своим заказом сразу: вебхуки о смене статуса несут только этот номер, а не ваш, и без этой связки вы не поймёте, какой заказ оплачен.
    • data.expiresAt — срок, до которого заказ ждёт оплаты.

    Ссылку на оплату можно отдать покупателю как угодно — редиректом, письмом или сообщением. Если у вас не сайт, а бот, та же ссылка уходит сообщением в чат: так и устроен приём оплаты криптой в Телеграм-боте.

    То же самое на Laravel

    На Laravel логика та же, но ключи живут в конфигурации, а запросы делает HTTP-клиент фреймворка. Сначала конфигурация:

    Laravel
    // config/services.php
    'cryptumpay' => [
        'key'    => env('CRYPTUMPAY_API_KEY'),
        'secret' => env('CRYPTUMPAY_API_SECRET'),
    ],

    Дальше — класс, который подписывает любой запрос. Обратите внимание на withBody: тело передаётся готовой строкой, а не массивом, иначе Laravel соберёт свой JSON и подпись не сойдётся.

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

    В контроллере оформления заказа остаётся создать заказ, сохранить его номер и отправить покупателя платить:

    Laravel
    $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

    На Python хватит стандартных hashlib, hmac и json плюс библиотеки requests. Функция подписи и два запроса — создать заказ и узнать его статус:

    Python
    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 проще подписи запроса, но у неё свои правила:

    • Берите сырое тело запроса. Ровно те байты, что пришли, до разбора JSON. Если разобрать JSON и собрать заново, строка может измениться — другой порядок полей, другие пробелы — и проверка сломается.
    • Считайте подпись как HMAC-SHA256 от строки bodyHash|apiKey. Здесь bodyHash — SHA-256 от сырого тела в шестнадцатеричном виде, ключ HMAC — секрет. Ни метода, ни пути, ни времени в этой строке нет.
    • Сравнивайте с заголовком X-Signature за постоянное время. Обычное сравнение строк прерывается на первом несовпавшем символе, и по времени ответа подпись можно подбирать; для этого есть hash_equals в PHP и hmac.compare_digest в Python.
    • Отсекайте дубли по заголовку X-IDMP-Key. Это UUID события; при повторной отправке он тот же. Запомнили — и повторный вебхук просто подтверждаете ответом 200, ничего не делая.
    • Отвечайте 2xx и быстро. Любой другой ответ, включая редирект, CryptumPay считает неудачей и присылает вебхук снова. Запросы шлюза приходят с User-Agent CryptumPayNotifier.

    Событий два типа. Вебхук merchantOrder приходит, когда заказ создан, и несёт ваши поля projectData вместе с номером заказа в CryptumPay. Вебхук customerOrder приходит на каждую смену статуса оплаты покупателя — created, pending, crediting, finished — и несёт номер заказа покупателя, merchantOrderId (тот самый номер вашего заказа в CryptumPay), cryptoCurrency, cryptoAmount и статус; сумма зачисления приходит только в finished.

    Обработчик на чистом PHP:

    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:

    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() — остальное не меняется.

    Обработчику выше для боевой нагрузки не хватает трёх вещей:

    • Очереди. Проверка статуса и выдача товара уходят в фоновую задачу, а сам обработчик только отвечает 200.
    • Журнала событий. Каждый принятый вебхук записывается с его X-IDMP-Key — по журналу и отсекаются дубли.
    • Поведения при сбое. Нужен план на случай, когда сервер лежал час и повторы кончились.

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

    Когда выдавать товар: статус заказа

    Истина о заказе живёт не в вебхуке и не в браузере покупателя, а в ответе API. GET-запрос /v1/orders с номером заказа возвращает customerOrderId — он появляется, когда покупатель начал оплату, — и блок financeSummary с полем status. Блок появляется, когда заказ покупателя дошёл до конечного состояния; пока его в ответе нет, заказ не оплачен, и код обязан это переживать. Статусов пять:

    • 0 — в процессе. Заказ создан, оплата не завершена.
    • 1 — на ручной проверке. Заказ ждёт проверки вручную.
    • 2 — отменён. Оплаты не будет.
    • 3 — оплачен и зачислён. Деньги на балансе.
    • 4 — зачисляется. Оплата принята, зачисление в процессе.

    Товар по документации выдаётся при статусе 3 или 4. При 0 и 1 заказ выполнять нельзя, даже если покупатель уже пишет в поддержку, что всё отправил: до конца проверки оплата не считается состоявшейся.

    Отдельно про возврат покупателя. После оплаты CryptumPay возвращает его на ваш домен по пути успеха или неудачи, который задан в проекте, и добавляет к адресу параметры cpayorder с номером заказа и cpayproject с номером проекта. Это удобно, чтобы показать нужную страницу, но доказательством оплаты не является: адрес видит и может набрать кто угодно. Страница «спасибо» показывает статус из вашей базы, а база обновляется только после проверки через API.

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

    Почему подпись не сходится: коды ошибок API

    Когда интеграция не заводится, ответ API почти всегда говорит, где искать. У CryptumPay коды в поле appCode такие:

    • 7000 — нет заголовка. Не передан один из x-api-key, x-signature, x-timestamp. Чаще всего — опечатка в имени заголовка.
    • 7001 — ключ не найден или отозван. Проверьте, что в переменных окружения лежит ключ того проекта, который вы настраиваете, и что он не отозван.
    • 7004 — запрос устарел. Между x-timestamp и временем сервера больше минуты. Чаще всего часы вашего сервера сбились; второй вариант — время передано в секундах, а не в миллисекундах.
    • 7005 — неверная подпись. Самая частая ошибка, и причина почти всегда одна из двух: подписана не та строка тела, что отправлена, или в путь попал домен, версия пропущена, либо в конце стоит лишняя косая черта.
    • 3002 — домен без HTTPS. Домен проекта или адрес вебхука заданы по HTTP; сертификат нужен действительный.

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

    Когда код не нужен

    Нужна ли вам серверная интеграция вообще, зависит только от того, кто задаёт сумму каждого заказа:

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

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

    Граница между виджетом и API проходит там, где сумму заказа должен задавать ваш код, а не покупатель; до этой границы задачу закрывает HTML-виджет для криптоплатежей вместо API и плагинов.

    Коротко

    Приём криптовалюты на PHP, Laravel или Python — это три куска кода:

    • Подпись запроса по пяти шагам, и отправляется ровно та строка, что подписана: иначе ошибка 7005.
    • Создание заказа через POST с компактным JSON; номер заказа из ответа сохраняется рядом со своим.
    • Обработчик вебхука, который проверяет HMAC по сырому телу, отсекает дубли и отвечает 200.

    Товар выдаётся только по статусу 3 или 4 из GET-запроса к API, а не по вебхуку и не по возврату покупателя.

    Вопросы и ответы

    Нужен ли для сайта на PHP какой-то особый платёжный шлюз?

    Нет. Любой шлюз с REST API подключается к PHP теми же средствами, что и к любому другому языку: расширение curl для запросов и встроенные функции hash и hash_hmac для подписи. Готовый SDK на PHP — удобство, а не условие: у CryptumPay алгоритм подписи расписан по шагам без привязки к языку, и вся интеграция из статьи умещается в пару сотен строк. Важнее, чтобы у шлюза были описаны алгоритм подписи и вебхуки: без них серверную интеграцию не собрать.

    Чем API криптоплатёжной системы отличается от API карточного эквайринга?

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

    Что будет, если мой сервер лежал, когда пришёл вебхук?

    CryptumPay повторит отправку: ответ не 2xx и редирект считаются неудачей, и уведомление приходит снова с нарастающим интервалом, до 12 попыток примерно за час. Если сервер не поднялся и за час, событие пропадёт, но не оплата: статус заказа всегда можно запросить GET-запросом по его номеру. Поэтому на вебхук стоит полагаться как на ускоритель, а не как на единственный источник правды.

    Можно ли отладить интеграцию до того, как проект проверят?

    Да. Настоящий заказ в CryptumPay создаётся сразу после регистрации, и оплата по нему примется; на баланс деньги зачислятся после проверки проекта. Так что вебхуки, статусы и выдачу товара можно отладить на реальном небольшом платеже, не дожидаясь проверки, а потом только дождаться зачисления.

    Начните приём криптовалют сейчас

    Создайте аккаунт и подключите оплату самостоятельно, или свяжитесь с нами и мы поможем всё настроить