API и надёжная интеграция

Интеграция платежей на PHP: создание и callback

В PHP следует проверять ошибки cURL и HTTP-код отдельно, читать callback из php://input один раз и сравнивать HMAC через hash_equals.

На PHP приём платежей собирается из двух файлов: один делает POST /api/v1/payments через cURL и отправляет покупателя на pay_url, второй принимает колбэк и проверяет X-Signature. Код короткий, но именно в PHP чаще всего стреляют вещи, которых нет в других языках: пробел перед открывающим тегом, отключённое расширение на хостинге и привычка считать, что curl_exec() без ошибки означает удачный платёж.

Дальше — код на чистом PHP 8, без фреймворка, и список того, что ломается на реальном хостинге.

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

Заголовок X-Nonce должен быть новым UUID на каждый запрос — значение живёт 10 минут, повтор вернёт 401 nonce already used. Библиотеку ради этого тянуть не нужно, хватит шестнадцати случайных байт с проставленной версией.

<?php
function uuid4(): string {
    $b = random_bytes(16);
    $b[6] = chr((ord($b[6]) & 0x0f) | 0x40);
    $b[8] = chr((ord($b[8]) & 0x3f) | 0x80);
    return vsprintf('%s%s-%s-%s-%s%s%s%s', str_split(bin2hex($b), 4));
}

$payload = json_encode([
    'amount'           => number_format($amountKopecks / 100, 2, '.', ''),
    'payment_currency' => 'RUB',
    'order_id'         => $orderId,
    'description'      => $title,
    'metadata'         => ['user_id' => $userId],
], JSON_UNESCAPED_UNICODE);

$ch = curl_init(getenv('ROLLYPAY_API_URL') . '/api/v1/payments');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => $payload,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CONNECTTIMEOUT => 3,
    CURLOPT_TIMEOUT        => 10,
    CURLOPT_HTTPHEADER     => [
        'Content-Type: application/json',
        'X-API-Key: ' . getenv('ROLLYPAY_API_KEY'),
        'X-Nonce: ' . uuid4(),
    ],
]);

Сумма уходит строкой вида «1500.00». Считайте её из целых копеек через number_format с точкой в качестве разделителя: локаль сервера может подставить запятую, и запрос отвалится с 400 amount must be positive.

Ошибка cURL и код ответа — две разные проверки

Самая живучая ошибка PHP-интеграций выглядит так: if (!$body) { /* не получилось */ }. Проблема в том, что ответ 400 terminal not found — это успешный вызов cURL с непустым телом. Транспорт отработал, платёж не создан, а код решает, что всё хорошо, и показывает покупателю пустую страницу оплаты.

$body  = curl_exec($ch);
$errno = curl_errno($ch);
$code  = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

if ($errno !== 0) {                       // сеть, DNS, TLS, таймаут
    throw new RuntimeException('curl: ' . curl_strerror($errno));
}
if ($code >= 400) {                       // сервер ответил, но отказал
    throw new RuntimeException("http {$code}: {$body}");
}

$data = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
header('Location: ' . $data['pay_url'], true, 303);

Разделяйте эти два случая и в логах. Транспортная ошибка — повод повторить запрос с новым nonce и тем же order_id. Отказ 400 повторять бессмысленно: нужно чинить данные заказа.

Колбэк: php://input читается один раз

Подпись считается от строки X-Timestamp + "." + сырое тело. Не от $_POST: колбэк приходит как JSON, и $_POST будет пустым. Не от повторно закодированного массива: json_encode(json_decode($raw)) переставит ключи и изменит экранирование, HMAC не сойдётся.

<?php
$raw = file_get_contents('php://input');
$ts  = $_SERVER['HTTP_X_TIMESTAMP'] ?? '';
$got = $_SERVER['HTTP_X_SIGNATURE'] ?? '';

$expected = hash_hmac('sha256', $ts . '.' . $raw, getenv('ROLLYPAY_SIGNING_SECRET'));

if (!hash_equals($expected, $got)) {
    http_response_code(401);
    exit;
}
if (!ctype_digit($ts) || abs(time() - (int) $ts) > 300) {
    http_response_code(401);
    exit;
}

$event = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
$pdo->prepare('INSERT INTO payment_events (payment_id, status) VALUES (?, ?)
               ON CONFLICT (payment_id) DO NOTHING')
    ->execute([$event['payment_id'], $event['status']]);

http_response_code(200);

Заголовки в PHP лежат в $_SERVER с префиксом HTTP_, дефисы заменены подчёркиваниями, регистр верхний: X-Signature становится $_SERVER['HTTP_X_SIGNATURE']. Функция getallheaders() удобнее, но на части сборок PHP-FPM её нет — $_SERVER надёжнее. И только hash_equals: сравнение через === выходит на первом несовпавшем символе, а по разнице во времени ответа подпись подбирается посимвольно. Механику разбирает статья про проверку подписи webhook.

Laravel и Symfony

Во фреймворках сырое тело доступно, даже если запрос уже разобран: в Laravel это $request->getContent(), в Symfony — $request->getContent() у объекта HttpFoundation\Request. Маршрут колбэка исключите из CSRF-проверки, иначе внешний POST будет отбит до вашего кода.

Лишний вывод до заголовков ломает ответ

PHP отправляет тело ответа при первом же echo, пробеле перед <?php или BOM в начале файла. После этого http_response_code(401) тихо не срабатывает: заголовки уже ушли со статусом 200. Платёжный сервис видит успешную доставку, повтора не будет, а вы — потерянную оплату.

  • Не ставьте закрывающий ?> в конце файлов: перевод строки после него — это уже вывод.
  • На проде display_errors = Off, а log_errors = On: текст warning в теле ответа ломает и статус, и подпись при отладке.
  • Не подключайте в файл колбэка шаблоны и хедеры сайта — только автозагрузчик и подключение к базе.

Что ломается на shared-хостинге

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

Что проверитьКакЕсли не так
Расширение cURLextension_loaded('curl')Включить в панели или писать через потоки
Исходящие соединенияТестовый вызов к API из скриптаЗапросить у хостера доступ наружу
max_execution_timeini_get('max_execution_time')Долгую выдачу вынести в cron-задачу
Секреты вне вебрутаОткрыть /.env в браузереПеренести файл выше public_html
HTTPS на адресе колбэкаВалидный сертификат без редиректаПочинить сертификат до подключения кассы

Магазин цифровых товаров на виртуальном хостинге неделю не мог понять, почему колбэки «не доходят»: адрес отдавал 301 с http на https, и POST терял тело при редиректе. Помог прямой https-адрес в настройках кассы.

Перед тем как включать приём денег

  1. Секреты в окружении. Ключ кассы и signing_secret — из getenv(), файл конфигурации лежит выше корня сайта.
  2. Таймауты заданы. CURLOPT_TIMEOUT и CURLOPT_CONNECTTIMEOUT есть в каждом вызове.
  3. Ответ 200 после записи. Строка в payment_events сохранена раньше, чем отправлен статус.
  4. Повтор безвреден. Уникальный индекс по payment_id, чтобы двойной колбэк не выдал товар дважды — см. идемпотентность платёжных запросов.
  5. Сумма сверяется. Значение из события сравнивается с суммой заказа в вашей базе.

Чек, налоги и возвраты остаются зоной продавца: платёжный сервис даёт техническое подключение, а не налоговый статус. Самозанятый выдаёт чек в «Мой налог» и держится в пределах 2,4 млн ₽ в год. Категорию проекта и доступные способы оплаты подтверждает модерация. Общую картину подключения даёт разбор API приёма платежей, а готовый сценарий для сайта — приём платежей на сайте.

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

Почему $_POST в обработчике колбэка пустой?

Колбэк приходит с типом application/json, а PHP наполняет $_POST только для form-urlencoded и multipart. Тело нужно читать напрямую: file_get_contents('php://input'). Оттуда же берутся байты для расчёта HMAC, потому что подпись считается до разбора JSON.

Как понять, что платёж не создался, если curl_exec вернул текст?

Проверяйте два условия отдельно: curl_errno() показывает транспортные сбои — DNS, TLS, таймаут, а curl_getinfo с CURLINFO_RESPONSE_CODE показывает ответ сервера. Ответ 400 terminal not found приходит как обычное тело и не считается ошибкой cURL. Проверка вида if (!$body) пропустит такой отказ.

Куда девается статус 401, который я выставляю в обработчике?

Скорее всего, вывод уже начался: пробел перед открывающим тегом, BOM в файле, перевод строки после закрывающего ?> или напечатанный warning. После первого байта тела заголовки отправлены, и http_response_code() ничего не меняет. Убирайте закрывающий тег, включайте log_errors и отключайте display_errors на проде.

Заголовок X-Signature в каком элементе $_SERVER?

В $_SERVER['HTTP_X_SIGNATURE']: PHP переводит имя в верхний регистр, меняет дефисы на подчёркивания и добавляет префикс HTTP_. Так же читается X-Timestamp. Функция getallheaders() короче, но на части сборок PHP-FPM недоступна, поэтому в платёжном коде надёжнее $_SERVER.

Что мешает приёму колбэков на виртуальном хостинге?

Чаще всего отключённое расширение cURL, закрытые исходящие соединения, короткий max_execution_time и редирект с http на https, при котором POST теряет тело. Ещё одна частая беда — файл с секретами внутри public_html: его можно открыть прямо в браузере. Проверьте всё это до подключения кассы.

Источники и документация