Приём оплаты Telegram Stars на PHP: sendInvoice, pre_checkout_query и successful_payment

Экосистема Telegram Stars (XTR) упрощает продажу цифровых товаров, подписок и премиум-контента внутри мессенджера. В отличие от классических платежных провайдеров (ЮKassa, Stripe), покупка Звёзд происходит без перенаправления на внешние формы — интерфейс встроен непосредственно в клиент Telegram. Однако технический протокол требует четкого соблюдения двухэтапного подтверждения покупки на вашем сервере.

В этом руководстве мы разберем архитектуру работы с Telegram Stars на чисто PHP, напишем надежный клиент для генерации счетов, реализуем валидацию pre-checkout запросов и обеспечим идемпотентность при финализации транзакции.

Архитектурный цикл платежа в Telegram Stars

Платежный процесс Telegram Stars состоит из пяти последовательных шагов:

  1. Инициализация: Сервер бота создает счет через метод sendInvoice или генерирует инвойс-ссылку через createInvoiceLink.
  2. Клик пользователя: Пользователь нажимает кнопку «Оплатить» в чате или Mini App.
  3. Проверка (Pre-checkout): Telegram отправляет вебхук pre_checkout_query на ваш сервер. Сервер обязан в течение 10 секунд подтвердить готовность отгрузить товар или отклонить транзакцию с объяснением причиный.
  4. Списание: Telegram списывает Звёзды с баланса пользователя.
  5. Финализация (Successful payment): Telegram присылает сообщение с объектом successful_payment. Бот выдает доступ к услуге или товару.

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

Выставление счета: метод sendInvoice

Для отправки счета в чат используется метод sendInvoice. Каждая транзакция должна содержать уникальный payload. Рекомендуется генерировать безопасный уникальный идентификатор заказа и сохранять его в базе данных в статусе pending.

<?php

declare(strict_types=1);

/**
* Отправка инвойса Telegram Stars в чат
*/
function sendStarsInvoice(int $chatId, string $title, string $description, int $starsAmount, PDO $pdo): array
{
$botToken = getenv('BOT_TOKEN');
if (!$botToken) {
throw new \RuntimeException('BOT_TOKEN environment variable is missing');
}

// 1. Генерируем уникальный payload заказа
$leadId = bin2hex(random_bytes(7)); // 14 символов hex
$payload = 'order_stars_' . $leadId;

// 2. Фиксируем заказ в базе данных
$stmt = $pdo->prepare("INSERT INTO orders (payload, chat_id, amount, status, created_at) VALUES (:payload, :chat_id, :amount, 'pending', NOW())");
$stmt->execute([
':payload' => $payload,
':chat_id' => $chatId,
':amount' => $starsAmount,
]);

$url = "https://api.telegram.org/bot{$botToken}/sendInvoice";

// 3. Формируем тело запроса
$postData = [
'chat_id' => $chatId,
'title' => $title,
'description' => $description,
'payload' => $payload,
'provider_token' => '', // Обязательно пустая строка для Stars
'currency' => 'XTR', // Валюта Telegram Stars
'prices' => json_encode([
['label' => $title, 'amount' => $starsAmount]
], JSON_UNESCAPED_UNICODE),
];

$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $postData,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);

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

if ($curlError !== '') {
throw new \RuntimeException("cURL error: {$curlError}");
}

if ($httpCode !== 200) {
throw new \RuntimeException("Telegram API HTTP error {$httpCode}: {$response}");
}

$data = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \RuntimeException('JSON parse error: ' . json_last_error_msg());
}

if (!isset($data['ok']) || $data['ok'] !== true) {
throw new \RuntimeException('Telegram API error: ' . ($data['description'] ?? 'Unknown error'));
}

return $data['result'];
}

Валидация и быстрый ответ на pre_checkout_query

Когда пользователь нажимает «Оплатить», сервер Telegram генерирует событие pre_checkout_query. На этом этапе вам необходимо за доли секунды проверить:

  • Существует ли заказ с указанным invoice_payload в вашей БД.
  • Совпадает ли запрашиваемая сумма total_amount с ценой в вашей системе.
  • Есть ли товар в наличии (или не куплен ли цифровой доступ ранее).

Если проверка прошла успешно, нужно вызвать метод answerPreCheckoutQuery с параметром ok = true. Если произошла ошибка — передать ok = false и указать человекопонятный текст в error_message, который отобразится клиенту в мессенджере.

<?php

declare(strict_types=1);

/**
* Обработка pre_checkout_query и отправка ответа
*/
function handlePreCheckoutQuery(array $preCheckoutQuery, PDO $pdo, string $botToken): void
{
$queryId = (string)$preCheckoutQuery['id'];
$payload = (string)($preCheckoutQuery['invoice_payload'] ?? '');
$totalAmount = (int)($preCheckoutQuery['total_amount'] ?? 0);
$currency = (string)($preCheckoutQuery['currency'] ?? '');

// 1. Проверка валюты
if ($currency !== 'XTR') {
answerPreCheckoutQuery($queryId, false, 'Неподдерживаемая валюта', $botToken);
return;
}

// 2. Поиск заказа в БД
$stmt = $pdo->prepare("SELECT amount, status FROM orders WHERE payload = :payload LIMIT 1");
$stmt->execute([':payload' => $payload]);
$order = $stmt->fetch(PDO::FETCH_ASSOC);

if (!$order) {
answerPreCheckoutQuery($queryId, false, 'Заказ не найден в системе', $botToken);
return;
}

if ($order['status'] !== 'pending') {
answerPreCheckoutQuery($queryId, false, 'Заказ уже оплачен или отменен', $botToken);
return;
}

if ((int)$order['amount'] !== $totalAmount) {
answerPreCheckoutQuery($queryId, false, 'Сумма оплаты не совпадает', $botToken);
return;
}

//Все проверки пройдены
answerPreCheckoutQuery($queryId, true, null, $botToken);
}

function answerPreCheckoutQuery(string $queryId, bool $ok, ?string $errorMessage, string $botToken): void
{
$url = "https://api.telegram.org/bot{$botToken}/answerPreCheckoutQuery";
$postData = [
'pre_checkout_query_id' => $queryId,
'ok' => $ok ? 'true' : 'false',
];

if (!$ok && $errorMessage !== null) {
$postData['error_message'] = $errorMessage;
}

$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $postData,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
]);

$response = curl_exec($ch);
curl_close($ch);
}

Финализация покупки и обработка successful_payment

После успешного списания Звёзд Telegram отправляет обычное сообщение, содержащее объект successful_payment. Важно защитить точку входа от подделок через заголовок X-Telegram-Bot-Api-Secret-Token и гарантировать идемпотентность через таблицу обработанных update_id.

<?php

declare(strict_types=1);

header('Content-Type: application/json');

$pdo = new PDO('mysql:host=localhost;dbname=bot_app;charset=utf8mb4', 'db_user', 'db_pass', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);

$secretToken = getenv('WEBHOOK_SECRET');
$botToken = getenv('BOT_TOKEN');

// 1. Защита вебхука через Secret Token
$incomingSecret = $_SERVER['HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN'] ?? '';
if ($secretToken && !hash_equals($secretToken, $incomingSecret)) {
http_response_code(403);
echo json_encode(['ok' => false, 'error' => 'Forbidden']);
exit;
}

$rawInput = file_get_contents('php://input');
if (!$rawInput) {
http_response_code(400);
exit;
}

$update = json_decode($rawInput, true);
if (json_last_error() !== JSON_ERROR_NONE || !isset($update['update_id'])) {
http_response_code(400);
exit;
}

$updateId = (int)$update['update_id'];

// 2. Дедупликация апдейтов (Идемпотентность)
$stmt = $pdo->prepare("INSERT IGNORE INTO processed_updates (update_id, created_at) VALUES (:id, NOW())");
$stmt->execute([':id' => $updateId]);

if ($stmt->rowCount() === 0) {
// Повторный запрос от Telegram — просто отдаем 200 OK
http_response_code(200);
echo json_encode(['ok' => true, 'status' => 'already_processed']);
exit;
}

// 3. Маршрутизация событий
if (isset($update['pre_checkout_query'])) {
handlePreCheckoutQuery($update['pre_checkout_query'], $pdo, $botToken);
} elseif (isset($update['message']['successful_payment'])) {
$message = $update['message'];
$payment = $message['successful_payment'];

$payload = (string)$payment['invoice_payload'];
$chargeId = (string)$payment['telegram_payment_charge_id']; // Идентификатор платежа в Telegram Stars
$totalStars = (int)$payment['total_amount'];

// Атомарное обновление статуса заказа
$stmt = $pdo->prepare("
UPDATE orders
SET status = 'paid', telegram_charge_id = :charge_id, paid_at = NOW()
WHERE payload = :payload AND status = 'pending'
");
$stmt->execute([
':charge_id' => $chargeId,
':payload' => $payload,
]);

if ($stmt->rowCount() > 0) {
// Заказ успешно переведен в оплачен - выдаем цифровой товар или услугу
// sendMessage($message['chat']['id'], "Спасибо! Оплата принята.");
}
}

http_response_code(200);
echo json_encode(['ok' => true]);

Типичные ошибки и подводные камни

  • Указание ненужного provider_token: Если при вызове sendInvoice с валютой XTR передать токен внешнего эквайринга (например, ЮKassa), Telegram вернет ошибку PAYMENT_PROVIDER_INVALID. Поле должно содержать пустую строку.
  • Превышение таймаута pre_checkout_query: Если ваш сервер отвечает на pre-checkout дольше 10 секунд, Telegram отменяет сделку и показывает пользователю ошибку. Долгие операции (запросы к сторонним API, генерация ключей) выносите на этап после получения successful_payment.
  • Отсутствие проверки тарифа в БД: Никогда не доверяйте сумме из pre_checkout_query без сверки с локальной БД. Злоумышленник может сформировать заниженную стоимость, если ваш код не проверяет total_amount.
  • Повторное оказание услуги при повторе апдейта: Из-за сбоев сети Telegram может прислать сообщение successful_payment несколько раз. Обязательно проверяйте telegram_payment_charge_id и обновляйте статус заказа атомарно через условие WHERE status = 'pending'.

Разработку сложного платежного функционала, Telegram Mini Apps и автоматизацию продаж в мессенджере вы можете доверить специалистам BotCreator.

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

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