Безпечне приймання платежів 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

Розбираємо, що автоматизувати в бізнесі та як це працює на практиці. Без спаму.