Безопасный прием платежей Telegram Stars на PHP: обработка pre_checkout_query и защита от фрода

Telegram Stars (Звезды) стали единым расчетным инструментом для продажи цифровых товаров и услуг внутри мессенджера. В отличие от традиционных платежных провайдеров, интеграция Stars имеет важные архитектурные нюансы: отсутствие стандартного токена провайдера, жесткий лимит времени на подтверждение транзакции и необходимость строгой проверки идемпотентности на стороне сервера.

В этой статье мы разберем сквозной процесс реализации платежей Telegram Stars на PHP: от генерации инвойса до обработки вебхуков с соблюдением требований безопасности.

Как устроен жизненный цикл платежа Stars

Процесс оплаты состоит из трех основных этапов:

  1. Инициация (sendInvoice): Бот отправляет пользователю специальное сообщение-счет. В качестве валюты передается строго XTR, а поле provider_token остается пустым.
  2. Проверка (pre_checkout_query): Когда пользователь нажимает кнопку оплаты и вводит пароль, Telegram отправляет на ваш Webhook запрос pre_checkout_query. У вашего сервера есть ровно 10 секунд, чтобы подтвердить наличие товара и актуальность цены через метод answerPreCheckoutQuery.
  3. Завершение (successful_payment): Если проверка прошла успешно, Telegram списывает Stars и отправляет в чат сообщение с объектом successful_payment. Только после этого шага товар считается оплаченным, и его можно выдавать клиенту.

Шаг 1. Отправка счета через sendInvoice

Для выставления счета используется стандартный метод sendInvoice. Главное отличие для Telegram Stars — валюта XTR. Дробные части (копейки) в Stars не поддерживаются, поэтому сумма передается как целое число звезд.

Уникальный идентификатор заказа (например, UUID или автоинкрементный ID из вашей БД) необходимо передавать в параметре payload. Это позволит связать платеж с конкретной записью при получении вебхука.

<?php
// send_invoice.php

function sendStarsInvoice(int $chatId, string $title, string $description, int $starsAmount, string $orderId): bool {
$token = getenv('TELEGRAM_BOT_TOKEN');
if (!$token) {
error_log('Telegram Bot Token не настроен.');
return false;
}

$url = 'https://api.telegram.org/bot' . $token . '/sendInvoice';

// Для Telegram Stars валюта строго XTR, а provider_token пустой
$payload = [
'chat_id' => $chatId,
'title' => $title,
'description' => $description,
'payload' => $orderId,
'provider_token' => '',
'currency' => 'XTR',
'prices' => json_encode([
['label' => 'Оплата цифрового товара', 'amount' => $starsAmount]
])
];

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($payload));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);

$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($httpCode !== 200 || !$response) {
error_log('Ошибка вызова sendInvoice. HTTP Code: ' . $httpCode);
return false;
}

$data = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE || !isset($data['ok']) || !$data['ok']) {
error_log('Некорректный ответ Telegram: ' . $response);
return false;
}

return true;
}

Шаг 2. Быстрая обработка pre_checkout_query

Когда пользователь подтверждает оплату, Telegram отправляет апдейт, содержащий pre_checkout_query. Ваш обработчик должен ответить в течение 10 секунд. Если сервер не уложится в этот лимит, транзакция будет автоматически отклонена мессенджером.

Важно: На этом этапе необходимо использовать блокировку строк в базе данных (например, SELECT FOR UPDATE в MySQL), чтобы избежать ситуации Race Condition (состояние гонки), когда два параллельных запроса пытаются зарезервировать один и тот же цифровой ключ или последнее место на вебинар.

<?php
// handle_pre_checkout.php

function handlePreCheckoutQuery(array $preCheckoutQuery, PDO $db): void {
$queryId = $preCheckoutQuery['id'];
$orderId = $preCheckoutQuery['invoice_payload'];
$totalAmount = (int)$preCheckoutQuery['total_amount'];

$db->beginTransaction();
try {
// Блокируем строку заказа для предотвращения одновременных изменений
$stmt = $db->prepare('SELECT status, price FROM orders WHERE id = :id FOR UPDATE');
$stmt->execute(['id' => $orderId]);
$order = $stmt->fetch(PDO::FETCH_ASSOC);

if (!$order) {
$db->rollBack();
answerPreCheckout($queryId, false, 'Заказ не найден в системе.');
return;
}

if ($order['status'] !== 'pending') {
$db->rollBack();
answerPreCheckout($queryId, false, 'Этот заказ уже обрабатывается или оплачен.');
return;
}

if ((int)$order['price'] !== $totalAmount) {
$db->rollBack();
answerPreCheckout($queryId, false, 'Сумма оплаты не совпадает с суммой заказа.');
return;
}

// Временно резервируем товар под этот платеж
$stmtUpdate = $db->prepare("UPDATE orders SET status = 'checking' WHERE id = :id");
$stmtUpdate->execute(['id' => $orderId]);

$db->commit();

// Подтверждаем успешность проверки
answerPreCheckout($queryId, true);
} catch (Exception $e) {
$db->rollBack();
error_log('Ошибка при обработке pre_checkout_query: ' . $e->getMessage());
answerPreCheckout($queryId, false, 'Внутренняя ошибка сервера. Повторите попытку.');
}
}

function answerPreCheckout(string $queryId, bool $ok, string $errorMessage = ''): void {
$token = getenv('TELEGRAM_BOT_TOKEN');
$url = 'https://api.telegram.org/bot' . $token . '/answerPreCheckoutQuery';

$payload = [
'pre_checkout_query_id' => $queryId,
'ok' => $ok
];
if (!$ok && $errorMessage !== '') {
$payload['error_message'] = $errorMessage;
}

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($payload));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 5); // Минимальный таймаут для быстрого ответа
curl_exec($ch);
curl_close($ch);
}

Шаг 3. Фиксация платежа successful_payment

После успешного списания средств Telegram присылает сообщение с объектом successful_payment. Получение этого апдейта — единственное юридическое и техническое подтверждение того, что Stars списаны со счета пользователя и зачислены на баланс вашего бота.

Здесь критически важно проверить уникальный идентификатор транзакции telegram_payment_charge_id на предмет повторной обработки (идемпотентность).

<?php
// handle_successful_payment.php

function handleSuccessfulPayment(array $message, PDO $db): void {
$payment = $message['successful_payment'];
$orderId = $payment['invoice_payload'];
$chargeId = $payment['telegram_payment_charge_id'];
$chatId = $message['chat']['id'];

$db->beginTransaction();
try {
$stmt = $db->prepare('SELECT status, charge_id FROM orders WHERE id = :id FOR UPDATE');
$stmt->execute(['id' => $orderId]);
$order = $stmt->fetch(PDO::FETCH_ASSOC);

if (!$order) {
$db->rollBack();
error_log("Критическая ошибка: Оплачен несуществующий заказ {$orderId}");
return;
}

// Защита от double-spending
if ($order['status'] === 'paid') {
$db->rollBack();
return; // Платеж уже был обработан ранее
}

// Обновляем статус заказа и сохраняем charge_id для будущих возвратов
$stmtUpdate = $db->prepare("
UPDATE orders
SET status = 'paid',
charge_id = :charge_id,
paid_at = NOW()
WHERE id = :id
");
$stmtUpdate->execute([
'id' => $orderId,
'charge_id' => $chargeId
]);

$db->commit();

// Выдача цифрового товара пользователю
deliverDigitalGoods($chatId, $orderId);

} catch (Exception $e) {
$db->rollBack();
error_log("Ошибка фиксации платежа для заказа {$orderId}: " . $e->getMessage());
}
}

function deliverDigitalGoods(int $chatId, string $orderId): void {
// Логика отправки ссылки, файла или активации подписки
}

Типичные ошибки при работе со Stars и способы их решения

1. Превышение таймаута в 10 секунд

Самая частая ошибка разработчиков — выполнение «тяжелых» операций (запросы к сторонним API, генерация PDF-файлов, отправка email) внутри обработчика pre_checkout_query. Если ваш скрипт не успевает ответить Telegram за отведенное время, пользователь получает ошибку, а транзакция прерывается.

Решение: В pre_checkout_query делайте только быструю проверку флага доступности в БД и резервирование строки. Все ресурсоемкие операции по генерации и отправке контента переносите на этап обработки successful_payment, желательно с использованием очереди задач (например, Laravel Queue или Yii2 Queue).

2. Отсутствие проверки secret_token на вебхуке

Поскольку вебхук принимает внешние POST-запросы, злоумышленник может симулировать отправку успешного платежа successful_payment, просто отправив валидный JSON на ваш URL-адрес.

Решение: При установке вебхука через setWebhook всегда передавайте сложный secret_token. При обработке входящего запроса обязательно проверяйте наличие заголовка X-Telegram-Bot-Api-Secret-Token и сверяйте его значение с сохраненным в конфигурации.

3. Ошибки при тестировании

При тестировании платежей разработчики часто забывают переключить бота в тестовый режим или пытаются использовать реальные Stars. В Telegram Bot API для тестирования платежей Stars не требуется реальный баланс — достаточно включить режим тестирования в настройках вашего бота через @BotFather (раздел Bot Settings -> Payments -> Sandbox).

Возврат платежей (Refund)

Если пользователю необходимо вернуть потраченные Stars (например, при отмене услуги), вы не можете сделать это вручную через интерфейс. Для этого в Bot API предусмотрен метод refundStarPayment. Для проведения возврата вам обязательно потребуется передать user_id покупателя и тот самый telegram_payment_charge_id, который вы сохранили в базу данных на этапе обработки successful_payment.

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

Если вам требуется профессиональная разработка сложных платежных решений и интеграция Telegram-ботов с вашими внутренними CRM-системами, обратитесь к специалистам BotCreator.

Новые статьи — в Telegram

Разбираем, что автоматизировать в бизнесе и как это работает на практике. Без спама.