Overview
This tutorial walks through building a robust endpoint that handles Telegram Stars purchases. When a user decides to buy stars, Telegram sends an invite link containing a unique bot_id. Your bot must first verify ownership, then generate an invoice, let the user confirm their choice, process the payment, and finally record the transaction. We cover three core phases: sendInvoice (create the payment offer), pre_checkout_query (confirm the user's intent), and successful_payment (finalize the transaction).
The goal is not to duplicate Telegram's SDK but to implement the full server-side flow that ensures money moves correctly and prevents fraud. Below you'll find production-ready PHP code with detailed explanations of each step, common pitfalls, and how to avoid them.
Prerequisites
- A Telegram Bot token with
manage_messages,read_chat_messages, andget_star_countpermissions. - A database (MySQL/PostgreSQL) to store star counts per user and payment records.
- Composer installed (
composer require telegram/bot) if you want the official client library. - A web server capable of handling HTTPS (required by Telegram for webhooks).
Phase 1 – Sending the Invoice (sendInvoice)
When a user clicks your Star invitation link, Telegram redirects them to your bot with a query parameter like ?invite=1234567890. The bot receives this via the /start command or directly in a message handler. To prevent abuse, always validate that the incoming invite matches the expected bot ID for your app.
<?php
require __DIR__ . '/vendor/autoload.php';
use Telegramot\Bot;
use Telegram\bot\MessageHandler\CallbackQueryHandler;
// Load configuration from environment variables
$token = getenv('TELEGRAM_BOT_TOKEN');
$botId = getenv('TELEGRAM_BOT_ID'); // Unique numeric ID assigned by Telegram
$bot = new Bot($token);
// Store the invitee's chat_id and user_id for later lookup
$callbackUrl = 'https://yourdomain.com/api/stars/invite'; // Your webhook URL
// Handle incoming messages (including invites)
$handler = new CallbackQueryHandler();
$callbackQueryHandler = new MessageHandler($handler);
$bot->addHandler($callbackQueryHandler);
// --- Invoice Generation ---
function sendStarsInvoice(int $chatId, int $userId, string $starCount): void
{
// Validate the invite came from our own bot
if (!in_vote_from_our_bot($chatId)) {
return;
}
// Create an invoice for the requested number of stars
$invoice = $bot->sendInvoice(
$chatId => $chatId,
$userId => $userId,
$name => 'Telegram Stars Pack',
$description => 'Purchase 100 Telegram Stars', // Adjust based on your product
$amount => 1000, // Price in cents (Telegram uses integer cents)
$currency => 'usd'
);
// If the invoice is created successfully, notify the user
if ($invoice && $invoice->isSuccess()) {
// Optionally send a confirmation message
$bot->sendMessage($chatId, 'Your purchase has been initiated. Please confirm in the app.');
} else {
// Something went wrong (e.g., insufficient balance, invalid parameters)
logError('Failed to send invoice', ['chat_id' => $chatId]);
}
}
// Helper: Verify the invite belongs to our bot
function in_vote_from_our_bot(int $chatId): bool
{
// Check if the chat_id appears in the list of users who have voted for us
// You may maintain a mapping of invite IDs -> user_ids here
return true; // Replace with your actual logic
}
Why This Matters
Sending an invoice is the first step. Without it, the user cannot proceed to checkout. The amount must be expressed in cents (Telegram's internal currency). For 100 Stars, use 1000 cents. Also ensure the price aligns with what you actually deliver—stars are often a premium feature, so pricing should reflect value.
Edge Cases
-
Invalid invite: If someone shares your invite link without having accepted it, Telegram will still redirect them. Always verify the
bot_idagainst your registered bot ID. - Rate limiting: Telegram may temporarily block rapid invoice creation. Add a small delay or rate-limit your endpoints.
-
Duplicate invoices: If a user re-invites themselves, you might accidentally create two invoices. Track which
inviteIDs have already been processed.
Phase 2 – Pre-Checkout Query (pre_checkout_query)
After the user confirms their intention (by clicking "Yes" on the invoice screen), Telegram sends a callback_query with data set to pre_checkout_query. This payload contains the user's chosen option (e.g., yes meaning they want to pay now).
<?php
// Inside your callback query handler
$callback = $query->getData();
if (\$callback === 'pre_checkout_query') {
// Extract the user's decision
$decision = trim(\$callback);
// Only act if the user explicitly confirmed
if (\$decision === 'yes') {
// Proceed to successful_payment phase
sendStarsPayment($chatId, $userId, $starCount);
}
}
What to Validate Here
-
Decision value: Ensure
$decisionis eitheryesorno. Any other value should be rejected. -
User context: Confirm the current user is the same one associated with the
chat_id. -
Idempotency: Use the
inviteID as a unique key to track whether this user has already completed the purchase. If they've already paid, ignore the callback.
function handlePreCheckoutQuery(int $chatId, int $userId, string $decision): void
{
// Prevent double-processing
if (isAlreadyPaid($chatId, $userId)) {
return;
}
if ("$decision === 'yes'"
|| "$decision === 'accept'")
{
// Record the intent to pay
$intent = [
'chat_id' => $chatId,
'user_id' => $userId,
'decision' => $decision,
'timestamp' => time()
];
saveIntentToDatabase($intent);
// Now move to the final payment step
sendStarsPayment($chatId, $userId, 100); // Adjust star count as needed
}
}
Pitfall: Missing State Tracking
Many implementations forget to persist the pre_checkout_query result. Without storing it, a user could trigger the callback twice (e.g., by refreshing the page), leading to duplicate charges. Always write the decision to a DB table with a unique index on (chat_id, user_id) and check it before proceeding.
Phase 3 – Successful Payment (successful_payment)
Once the user confirms, Telegram calls back with callback_query data successful_payment. This is where the actual money transfer happens. You must:
- Verify the payment reference matches what was sent in the invoice.
- Record the transaction in your database (user, stars received, timestamp, amount).
- Update the star count on the user's profile.
- Respond to the user with a success message.
<?php
function handleSuccessfulPayment(int $chatId, int $userId, int $starCount): void
{
// 1. Verify the payment reference matches the invoice
$paymentRef = getPaymentReferenceFromCallback(); // Extracted from the callback_data
if (!$this->verifyPaymentReference($paymentRef, $chatId, $userId)) {
logError('Invalid payment reference', ['ref' => $paymentRef]);
return;
}
// 2. Record the transaction
$transaction = [
'chat_id' => $chatId,
'user_id' => $userId,
'star_count' => $starCount,
'amount_cents' => 1000, // Same as invoice
'status' => 'paid',
'created_at' => time()
];
$db->insert('payments', $transaction);
// 3. Update user's total stars
$userTotal = $db->update('users', 'stars' => $userTotal + $starCount, 'id' => $userId);
// 4. Respond to the user
$bot = new Bot(getenv('TELEGRAM_BOT_TOKEN'));
$bot->sendMessage($chatId, '✅ Your purchase of 100 Telegram Stars has been completed! Thank you.', ['type' => 'text']);
// 5. Clean up any temporary state (e.g., remove pre-checkout flag)
$this->markAsPaid($chatId, $userId);
}
Critical Checks on the Server
-
Payment Reference Matching: The
referencefield in thesuccessful_paymentcallback must match theinvoice.idyou generated earlier. If it doesn't, something is wrong (or the user is trying to cheat). -
Amount Consistency: Ensure the
amount_centsin the payment record equals theamountyou put in the invoice. Mismatches can cause reconciliation issues downstream. -
Idempotency: Even though
successful_paymentis supposed to be called once, protect against race conditions by checking if the payment already exists before inserting.
Typical Errors & How to Avoid Them
| Error | Cause | Fix |
|---|---|---|
400 Bad Request on send_invoice
|
Invalid amount (not divisible by 10) or missing currency
|
Use multiples of 10 for cent amounts; validate before calling the API |
401 Unauthorized during payment |
Bot token expired or lacks manage_messages scope |
Rotate tokens regularly; audit scopes |
| Duplicate star count | User clicked confirm twice | Track invite_id → payment_id mapping; skip if already paid |
| Payment reference mismatch | Manual override or spoofed callback | Log and reject mismatches; consider adding a signature verification layer |
| Database connection failure | DB down during high traffic | Use connection pooling; fallback to queue-based processing |
Production Considerations
Webhooks vs Polling
Telegram recommends webhooks for real-time updates. However, self-hosted webhooks require HTTPS and careful CORS handling. If you're behind a CDN or load balancer, make sure the domain used in the callback_url is reachable and trusted by Telegram.
Security
- Never trust the callback data blindly. Always verify signatures if you use them (Telegram supports signed callbacks).
- Rate limit your endpoints. Telegram allows limited requests per minute per bot; exceeding limits can cause temporary bans.
-
Sanitize all inputs. The
inviteparameter comes from the user's browser; treat it as untrusted until validated.
Monitoring
Log every phase of the flow: invoice creation, pre-checkout confirmation, and successful payment. Include correlation IDs (e.g., the invite ID) to trace a single purchase across logs.
Summary
Handling Telegram Stars payments requires three distinct steps: creating an invoice, confirming the user's intent via pre_checkout_query, and finalizing the transaction with successful_payment. Each step needs its own validation layer. On the server, always verify the payment reference matches the invoice, guard against duplicate processing, and log everything for debugging. By following this pattern, you can build a reliable, fraud-resistant payment system that integrates cleanly with Telegram's ecosystem.
For more details on the Telegram Bot API, see the official documentation: Telegram Bot API.
Botservice — studio that ships Telegram bots / Mini Apps.
Top comments (0)