Secure Telegram Stars Payments in PHP: Handling pre_checkout_query and Fraud Prevention

Telegram Stars (Stars) have become a unified payment tool for selling digital goods and services within the messenger. Unlike traditional payment providers, Stars integration has important architectural nuances: the absence of a standard provider token, a strict time limit for transaction confirmation, and the need for strict idempotency verification on the server side.

In this article, we will look at the end-to-end process of implementing Telegram Stars payments in PHP: from invoice generation to webhook processing in compliance with security requirements.

How the Stars Payment Lifecycle Works

The payment process consists of three main stages:

  1. Initiation (sendInvoice): The bot sends a special invoice message to the user. The currency passed is strictly XTR, and the provider_token field remains empty.
  2. Verification (pre_checkout_query): When the user clicks the payment button and enters their password, Telegram sends a pre_checkout_query request to your Webhook. Your server has exactly 10 seconds to confirm the availability of the product and the relevance of the price via the answerPreCheckoutQuery method.
  3. Completion (successful_payment): If the verification is successful, Telegram deducts the Stars and sends a message with the successful_payment object to the chat. Only after this step is the product considered paid for and can be delivered to the client.

Step 1. Sending an Invoice via sendInvoice

The standard sendInvoice method is used to issue an invoice. The main difference for Telegram Stars is the XTR currency. Fractional parts (cents) are not supported in Stars, so the amount is passed as an integer number of stars.

A unique order identifier (for example, a UUID or an auto-incrementing ID from your database) must be passed in the payload parameter. This will allow you to link the payment to a specific record when receiving the webhook.

<?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;
}

Step 2. Fast Processing of pre_checkout_query

When the user confirms the payment, Telegram sends an update containing pre_checkout_query. Your handler must respond within 10 seconds. If the server does not meet this limit, the transaction will be automatically declined by the messenger.

Important: At this stage, it is necessary to use row locking in the database (for example, SELECT FOR UPDATE in MySQL) to avoid a Race Condition, where two parallel requests attempt to reserve the same digital key or the last seat for a webinar.

<?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);
}

Step 3. Recording the successful_payment

After successful deduction of funds, Telegram sends a message with the successful_payment object. Receiving this update is the only legal and technical confirmation that Stars have been deducted from the user's account and credited to your bot's balance.

Here, it is critically important to check the unique transaction identifier telegram_payment_charge_id for duplicate processing (idempotency).

<?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 {
// Логика отправки ссылки, файла или активации подписки
}

Typical Errors When Working with Stars and How to Solve Them

1. Exceeding the 10-Second Timeout

The most common developer mistake is performing "heavy" operations (requests to third-party APIs, PDF generation, sending emails) inside the pre_checkout_query handler. If your script fails to respond to Telegram within the allotted time, the user receives an error and the transaction is aborted.

Solution: In pre_checkout_query, perform only a quick check of the availability flag in the database and row reservation. Move all resource-intensive operations for generating and sending content to the successful_payment processing stage, preferably using a task queue (for example, Laravel Queue or Yii2 Queue).

2. Lack of secret_token Verification on the Webhook

Since the webhook accepts external POST requests, an attacker could simulate sending a successful successful_payment by simply sending a valid JSON to your URL.

Solution: When setting up a webhook via setWebhook, always pass a complex secret_token. When processing an incoming request, be sure to check for the presence of the X-Telegram-Bot-Api-Secret-Token header and compare its value with the one saved in the configuration.

3. Testing Errors

When testing payments, developers often forget to switch the bot to test mode or try to use real Stars. In Telegram Bot API, testing Stars payments does not require a real balance — it is enough to enable test mode in your bot's settings via @BotFather (Bot Settings -> Payments -> Sandbox section).

Refunds

If a user needs to refund spent Stars (for example, when canceling a service), you cannot do this manually through the interface. For this, the Bot API provides the refundStarPayment method. To process a refund, you will definitely need to pass the buyer's user_id and the very same telegram_payment_charge_id that you saved in the database during the successful_payment processing stage.

Design your bot's database architecture in advance, allocating fields for storing transaction identifiers and unique order keys.

If you require professional development of complex payment solutions and integration of Telegram-бот s with your internal CRM systems, contact the specialists at BotCreator.

New articles on Telegram

We explain what to automate in your business and how it works in practice. No spam.