На 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-хостинге
Дешёвый тариф — отдельный источник проблем, и почти все они находятся до первого платежа, если знать, куда смотреть.
| Что проверить | Как | Если не так |
|---|---|---|
| Расширение cURL | extension_loaded('curl') | Включить в панели или писать через потоки |
| Исходящие соединения | Тестовый вызов к API из скрипта | Запросить у хостера доступ наружу |
max_execution_time | ini_get('max_execution_time') | Долгую выдачу вынести в cron-задачу |
| Секреты вне вебрута | Открыть /.env в браузере | Перенести файл выше public_html |
| HTTPS на адресе колбэка | Валидный сертификат без редиректа | Починить сертификат до подключения кассы |
Магазин цифровых товаров на виртуальном хостинге неделю не мог понять, почему колбэки «не доходят»: адрес отдавал 301 с http на https, и POST терял тело при редиректе. Помог прямой https-адрес в настройках кассы.
Перед тем как включать приём денег
- Секреты в окружении. Ключ кассы и
signing_secret— изgetenv(), файл конфигурации лежит выше корня сайта. - Таймауты заданы.
CURLOPT_TIMEOUTиCURLOPT_CONNECTTIMEOUTесть в каждом вызове. - Ответ 200 после записи. Строка в
payment_eventsсохранена раньше, чем отправлен статус. - Повтор безвреден. Уникальный индекс по
payment_id, чтобы двойной колбэк не выдал товар дважды — см. идемпотентность платёжных запросов. - Сумма сверяется. Значение из события сравнивается с суммой заказа в вашей базе.
Чек, налоги и возвраты остаются зоной продавца: платёжный сервис даёт техническое подключение, а не налоговый статус. Самозанятый выдаёт чек в «Мой налог» и держится в пределах 2,4 млн ₽ в год. Категорию проекта и доступные способы оплаты подтверждает модерация. Общую картину подключения даёт разбор API приёма платежей, а готовый сценарий для сайта — приём платежей на сайте.