Process Telegram Stars Payments in PHP: Invoices, Pre-Checkout, and Webhooks

Telegram Stars (XTR) allow bots and Mini Apps to accept payments for digital goods and services directly within the Telegram ecosystem. Unlike traditional payment providers, Telegram acts as the payment processor for Stars, which simplifies the integration but introduces specific webhook requirements.

This tutorial demonstrates how to implement the Telegram Stars payment flow in native PHP. We will cover issuing an invoice, answering the critical pre_checkout_query webhook, and securely processing the final successful_payment notification. We do not cover database persistence or frontend Mini App UI.

Step 1: Issuing a Telegram Stars Invoice

To charge a user in Telegram Stars, you must call the sendInvoice method. For Stars payments, the currency parameter must be set to XTR, and the provider_token must be left as an empty string.

Always generate a unique, tamper-proof payload (such as a signed order ID or a database primary key) to track the transaction state across the payment lifecycle.

<?php
// send_invoice.php

$botToken = getenv('TELEGRAM_BOT_TOKEN');
if (!$botToken) {
die("TELEGRAM_BOT_TOKEN environment variable is not set.\n");
}

// Generate a unique payload for tracking
$orderId = bin2hex(random_bytes(8));
$payload = json_encode([
'order_id' => $orderId,
'user_id' => 123456789,
'item_sku' => 'premium_status_30d'
]);

$parameters = [
'chat_id' => 123456789, // The recipient's Telegram user ID
'title' => '30 Days Premium Access',
'description' => 'Unlock premium features in our Telegram Mini App.',
'payload' => $payload,
'provider_token' => '', // Must be empty for Telegram Stars
'currency' => 'XTR', // Must be XTR for Telegram Stars
'prices' => [
[
'label' => '30 Days Premium',
'amount' => 50 // Price in Stars (not multiplied by 100)
]
]
];

$ch = curl_init("https://api.telegram.org/bot{$botToken}/sendInvoice");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($parameters));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);

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

if ($httpCode !== 200) {
throw new Exception("Failed to send invoice. HTTP Code: {$httpCode}. Response: {$response}");
}

$responseData = json_decode($response, true);
if (!$responseData || !isset($responseData['ok']) || !$responseData['ok']) {
throw new Exception("Telegram API Error: " . ($responseData['description'] ?? 'Unknown error'));
}

echo "Invoice sent successfully. Message ID: " . $responseData['result']['message_id'] . "\n";

Step 2: Handling the Webhook Lifecycle

When a user clicks "Pay" on the invoice, the Telegram client initiates the payment flow. This triggers two distinct webhook updates on your server:

1. pre_checkout_query: Sent immediately before the payment is processed. Your server has exactly 10 seconds to respond using answerPreCheckoutQuery. If you do not respond, or if you respond with ok: false, the transaction is aborted. 2. successful_payment: Sent inside a standard message update after the payment is successfully completed. This is your cue to provision the digital product.

Step 3: The Webhook Controller

This script handles incoming webhook updates, validates the secret token header, routes the payload, and responds to Telegram.

<?php
// webhook.php

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

$botToken = getenv('TELEGRAM_BOT_TOKEN');
$secretToken = getenv('TELEGRAM_SECRET_TOKEN'); // Set when configuring setWebhook

// 1. Validate the incoming request source
$headers = getallheaders();
$receivedToken = $headers['X-Telegram-Bot-Api-Secret-Token'] ?? '';

if ($secretToken && !hash_equals($secretToken, $receivedToken)) {
http_response_code(403);
echo json_encode(['error' => 'Unauthorized']);
exit;
}

// 2. Parse the incoming JSON payload
$rawInput = file_get_contents('php://input');
$update = json_decode($rawInput, true);

if (json_last_error() !== JSON_ERROR_NONE) {
http_response_code(400);
echo json_encode(['error' => 'Invalid JSON']);
exit;
}

// 3. Route: Pre-Checkout Query
if (isset($update['pre_checkout_query'])) {
handlePreCheckout($update['pre_checkout_query'], $botToken);
exit;
}

// 4. Route: Successful Payment
if (isset($update['message']['successful_payment'])) {
handleSuccessfulPayment($update['message'], $botToken);
exit;
}

// Fallback for other update types
echo json_encode(['status' => 'ignored']);

/**
* Answer the pre-checkout query to authorize or deny the transaction.
*/
function handlePreCheckout(array $query, string $botToken): void
{
$queryId = $query['id'];
$payload = json_decode($query['invoice_payload'], true);

// Perform server-side checks (e.g., check stock, verify user eligibility)
$isAvailable = true;

if (!$payload || !$isAvailable) {
sendPreCheckoutAnswer($queryId, false, "This item is currently unavailable.", $botToken);
return;
}

// Approve the payment
sendPreCheckoutAnswer($queryId, true, null, $botToken);
}

/**
* Send the answerPreCheckoutQuery request to Telegram.
*/
function sendPreCheckoutAnswer(string $queryId, bool $ok, ?string $errorMessage, string $botToken): void
{
$parameters = [
'pre_checkout_query_id' => $queryId,
'ok' => $ok
];

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

$ch = curl_init("https://api.telegram.org/bot{$botToken}/answerPreCheckoutQuery");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($parameters));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);

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

/**
* Process the finalized payment and provision the digital product.
*/
function handleSuccessfulPayment(array $message, string $botToken): void
{
$payment = $message['successful_payment'];
$payload = json_decode($payment['invoice_payload'], true);

if (!$payload) {
// Log error: received a payment with an unparseable payload
return;
}

// Verify currency and amount match your database records for this SKU
$expectedCurrency = 'XTR';
$expectedAmount = 50; // Stars

if ($payment['currency'] !== $expectedCurrency || $payment['total_amount'] !== $expectedAmount) {
// Log error: potential price tampering attempt
return;
}

// TODO: Mark the order as paid in your database using $payload['order_id']
// TODO: Provision the digital product to the user

// Acknowledge the webhook to Telegram
echo json_encode(['status' => 'success']);
}

Production Considerations and Pitfalls

* Strict Response Timeout: The pre_checkout_query has a strict 10-second timeout limit. Do not perform heavy processing, external API calls, or complex database migrations inside this block. Keep your lookup queries optimized. * Idempotency: Telegram may retry webhook deliveries if your server experiences transient network issues. To prevent double-provisioning, store the telegram_payment_charge_id (found inside successful_payment) in your database with a unique constraint. If a webhook arrives with an already-processed charge ID, acknowledge it with a 200 OK and skip provisioning. * Price Tampering: Never trust the price sent back in the webhook blindly. Always use the invoice_payload to look up the expected price in your database and compare it against the total_amount returned in the successful_payment object.

If you need help scaling your Telegram integrations or building custom transactional flows, consider working with BotCreator — studio that ships Telegram bots / Mini Apps.

New articles on Telegram

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