Accepting Telegram Stars Payments in PHP: sendInvoice, pre_checkout_query, and successful_payment

The Telegram Stars (XTR) ecosystem simplifies the sale of digital goods, subscriptions, and premium content inside the messenger. Unlike classic payment providers (Yookassa, Stripe), purchasing Stars occurs without redirection to external forms — the interface is built directly into the Telegram client. However, the technical protocol requires strict compliance with a two-step purchase confirmation on your server.

In this guide, we will break down the architecture of working with Telegram Stars in pure PHP, write a reliable client for generating invoices, implement pre-checkout request validation, and ensure idempotency during transaction finalization.

Architectural Payment Cycle in Telegram Stars

The Telegram Stars payment process consists of five sequential steps:

  1. Initialization: The bot server creates an invoice via the sendInvoice method or generates an invoice link via createInvoiceLink.
  2. User Click: The user clicks the "Pay" button in a chat or Mini App.
  3. Verification (Pre-checkout): Telegram sends a pre_checkout_query webhook to your server. The server must confirm readiness to deliver the goods or decline the transaction with an explanation within 10 seconds.
  4. Deduction: Telegram deducts Stars from the user's balance.
  5. Finalization (Successful payment): Telegram sends a message containing the successful_payment object. The bot grants access to the service or product.

An important difference between Telegram Stars and fiat currencies: the provider_token value must be an empty string "", and the currency code must strictly be XTR. In addition, in Stars, 1 unit equals 1 Star (unlike rubles or dollars, where amount is passed in kopecks/cents).

Issuing an Invoice: The sendInvoice Method

The sendInvoice method is used to send an invoice to a chat. Each transaction must contain a unique payload. It is recommended to generate a secure unique order ID and save it in the database with a pending status.

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

Validation and Fast Response to pre_checkout_query

When a user clicks "Pay", the Telegram server generates a pre_checkout_query event. At this stage, you need to check the following in a fraction of a second:

  • Whether an order with the specified invoice_payload exists in your DB.
  • Whether the requested total_amount matches the price in your system.
  • Whether the product is in stock (or whether digital access hasn't been purchased previously).

If the check is successful, you must call the answerPreCheckoutQuery method with the parameter ok = true. If an error occurs, pass ok = false and specify a human-readable text in error_message, which will be displayed to the client in the messenger.

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

Purchase Finalization and Handling successful_payment

After successfully deducting Stars, Telegram sends a regular message containing a successful_payment object. It is important to protect the endpoint against spoofing using the X-Telegram-Bot-Api-Secret-Token header and ensure idempotency using a table of processed update_ids.

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

Common Mistakes and Pitfalls

  • Specifying an unnecessary provider_token: If you pass an external acquiring token (for example, Yookassa) when calling sendInvoice with currency XTR, Telegram will return the PAYMENT_PROVIDER_INVALID error. The field must contain an empty string.
  • Exceeding the pre_checkout_query timeout: If your server takes longer than 10 seconds to respond to a pre-checkout query, Telegram cancels the transaction and shows an error to the user. Move long operations (third-party API requests, key generation) to the stage after receiving successful_payment.
  • Lack of pricing checks in the DB: Never trust the amount from pre_checkout_query without verifying it against your local DB. An attacker could forge a lower price if your code does not validate total_amount.
  • Duplicate service fulfillment on repeated updates: Due to network glitches, Telegram may send the successful_payment message multiple times. Always verify telegram_payment_charge_id and update статус заказа atomically using the WHERE status = 'pending' condition.

You can entrust the development of complex payment functionality, Telegram Mini Apps, and sales automation in the messenger to the specialists at BotCreator.

New articles on Telegram

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