What we build and what we do not claim
This tutorial shows a minimal, working PHP path for accepting Telegram Stars through the Bot API. We will:
1. Send an invoice with sendInvoice denominated in XTR (the Stars currency code). 2. Handle pre_checkout_query and only then answerPreCheckoutQuery. 3. Receive message.successful_payment and verify the server-side facts that Telegram commits to: the charge id, the total_amount in Stars, and the invoice payload you put on the invoice in step 1. 4. Avoid the common pitfalls: answering too late, treating successful_payment as if it were Stars *received by your bot account* (it is not — Stars are a virtual currency that must be withdrawn via getStarTransactions/Fragment), and trusting client-side totals.
I will not cover KYC, Fragment withdrawals, or tax treatment. Treat this as the integration layer, not the finance layer.
---
1. Sending the invoice in XTR
Stars invoices are regular invoices whose currency is the literal string XTR. There is no provider_token for Stars — you omit it. Telegram shows the price as ⭐ and the user pays from their Stars balance (or buys Stars inside the flow). The payload is your own opaque string, up to 128 bytes; this is what lets you correlate a successful_payment back to your order.
<?php
// send_invoice.php — invoked by your bot to start a Stars checkout.
$token = getenv('BOT_TOKEN'); // never hardcode
$chatId = (int) $update['message']['chat']['id']; // from the trigger update
$payload = bin2hex(random_bytes(7)); // order id, ≤128 bytes, server-generated
$stars = 1; // integer, no decimals for Stars
// Persist the order BEFORE sendMessage so we never accept a payment
// we cannot look up.
$orderId = saveOrder([
'payload' => $payload,
'chat_id' => $chatId,
'stars' => $stars,
'status' => 'pending',
'created_at'=> gmdate('c'),
]);
$body = [
'chat_id' => $chatId,
'title' => 'Pro plan (1 month)',
'description' => 'Unlocks Pro features for 30 days.',
'payload' => $payload,
'currency' => 'XTR',
'prices' => json_encode([['label' => 'Pro 1m', 'amount' => $stars]]),
// provider_token must be OMITTED for XTR — Telegram rejects it otherwise.
];
$ch = curl_init('https://api.telegram.org/bot'.$token.'/sendInvoice');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($body),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$resp = json_decode($raw, true);
if ($status !== 200 || !($resp['ok'] ?? false)) {
markOrderFailed($orderId, $raw);
throw new RuntimeException('sendInvoice failed: '.$raw);
}
A few things worth pinning down:
- prices[].amount is the number of Stars. Telegram does not accept fractional Stars. - payload is your correlation key. Anything the user could tamper with — e.g. a price — must not be in it. - Persist first, send second. If sendInvoice fails, mark the row failed so you do not later accept a Stars charge for an order you never created.
---
2. Responding to pre_checkout_query
When the user confirms, Telegram sends pre_checkout_query with the same invoice_payload and total_amount you saw. You have ~10 seconds to call answerPreCheckoutQuery. If you time out, Telegram cancels the payment and shows the user an error. This is the right place to re-check price and availability — never trust callback_query data for money.
<?php
// pre_checkout.php — invoked for every update with pre_checkout_query.
$token = getenv('BOT_TOKEN');
$pcq = $update['pre_checkout_query'];
$pcqId = $pcq['id'];
$payload = $pcq['invoice_payload'];
$totalStars = (int) $pcq['total_amount'];
$order = loadOrderByPayload($payload);
$ok = $order
&& $order['status'] === 'pending'
&& (int) $order['stars'] === $totalStars
&& !$order['expires_at_gmt'] // optional: refuse if order window elapsed
? true : false;
$body = [
'pre_checkout_query_id' => $pcqId,
'ok' => $ok ? 'true' : 'false',
];
if (!$ok) {
// Show user-facing reason; never include secrets.
$body['error_message'] = 'This order is no longer available.';
}
$ch = curl_init('https://api.telegram.org/bot'.$token.'/answerPreCheckoutQuery');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($body),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
]);
$raw = curl_exec($ch);
curl_close($ch);
$resp = json_decode($raw, true);
if (!($resp['ok'] ?? false)) {
// Log, but do not retry forever — the 10s window is already closing.
error_log('answerPreCheckoutQuery failed: '.$raw);
}
Key checks at this step:
- invoice_payload must resolve to a real order. - total_amount must equal the Stars you stored. If your UI offers discounts, recompute the final Stars here, not in payload. - Status must be pending. A second pre_checkout_query for a payload you already approved is a sign of replay — refuse it.
---
3. Verifying successful_payment
When Telegram charges the user, your webhook receives message.successful_payment. The block contains:
- currency === 'XTR'. - total_amount — Stars charged, integer. - invoice_payload — same string you put on the invoice. - telegram_payment_charge_id — Telegram's unique id for this charge. Store it. - provider_payment_charge_id — for Stars this is empty; do not assume the field exists.
You must treat this update as authoritative for the Stars receipt only. Stars are a Telegram-side virtual balance; they are not auto-deposited into your bank. To see what your bot has earned, call getStarTransactions.
<?php
// successful_payment.php — invoked once per confirmed Stars charge.
$token = getenv('BOT_TOKEN');
$sp = $update['message']['successful_payment'];
$payload = $sp['invoice_payload'];
$totalStars = (int) $sp['total_amount'];
$currency = $sp['currency'];
$chargeId = $sp['telegram_payment_charge_id'];
$userId = (int) $update['message']['from']['id'];
if ($currency !== 'XTR') {
// Defensive: refuse to credit non-Stars here.
error_log('Unexpected currency on successful_payment: '.json_encode($sp));
http_response_code(200);
exit;
}
// Idempotency: if we have already credited this charge_id, no-op.
if (alreadyCredited($chargeId)) {
http_response_code(200);
exit;
}
$pdo->beginTransaction();
try {
$order = loadOrderByPayloadForUpdate($payload); // SELECT ... FOR UPDATE
if (!$order || $order['status'] !== 'pending') {
throw new RuntimeException('Order not pending for payload '.$payload);
}
if ((int) $order['stars'] !== $totalStars) {
throw new RuntimeException('Stars mismatch on payload '.$payload);
}
creditUser($userId, $order); // your domain logic
markOrderPaid($order['id'], $chargeId);
recordCharge($chargeId, $order['id']); // unique index on charge_id
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
error_log('successful_payment error: '.$e->getMessage());
// Still return 200 — Telegram will retry otherwise. Re-check later.
}
http_response_code(200);
What you actually verify here:
1. currency === 'XTR'. Any other value means the wrong code path was hit. 2. invoice_payload maps to an order still in pending. 3. total_amount matches the Stars you expected. 4. telegram_payment_charge_id is unique in your ledger — duplicate webhook deliveries are real, so this is your idempotency key.
After crediting, you may send a confirmation message with sendMessage. Keep the reply short; the user has already paid.
---
4. Optional / production notes
- Webhook secret. Pass a random secret_token when calling setWebhook and verify it header-side. Reject updates that do not carry it. - Update idempotency. Persist update_id and skip anything ≤ the highest you have processed. Webhooks retry; do not double-credit. - Refunds. For Stars, Telegram exposes refunds via the Bot API; treat them as new successful_payment-style events with their own charge ids and reconcile them like any reversal. - Logging. Store the raw successful_payment JSON for at least 90 days — it is the only proof of receipt you get before getStarTransactions. - Test payments. Use @BotFather → *Payments* → a test environment if available in your region; otherwise run small Stars charges in production with a refund loop. - Withdrawing Stars. Stars are not fiat. To cash out, call getStarTransactions, then follow Fragment's flow. Do not promise users that paying equals payout to you.
---
5. Common pitfalls
- Answering pre_checkout_query with a generic HTTP 200 to your webhook, instead of calling answerPreCheckoutQuery. Telegram will still cancel the payment after the timeout. The two responses are different. - Reading the Stars total from the keyboard callback. The callback_query from "Buy" is for navigation only; money lives in pre_checkout_query and successful_payment. - Putting the price inside payload. payload is visible to the client surface and is not a signed value. Keep it opaque, recompute on the server. - Forgetting provider_token. For XTR, the field must be absent. Including it makes sendInvoice return ok:false. - Treating Stars as banked cash. They are not. Your accounting is successful_payment records minus refunds, with a separate payout story via Fragment. - Retrying successful_payment forever. Return 200 after logging. If you keep returning errors, Telegram keeps resending and you risk double-credits — guard with the telegram_payment_charge_id unique index.
---
6. End-to-end shape
1. User taps *Buy*. Your webhook receives a callback_query; you answerCallbackQuery and call sendInvoice with currency=XTR. 2. Telegram shows a Stars confirmation sheet. On confirm, it sends pre_checkout_query. You answerPreCheckoutQuery only after re-checking the order. 3. On charge, Telegram sends message.successful_payment. You verify currency, total_amount, invoice_payload, and telegram_payment_charge_id, credit the user idempotently, then send a short confirmation with sendMessage.
That is the complete loop for Stars. Keep the money facts in two places only: the invoice_payload you generated, and the successful_payment Telegram sends back.
---
If you want a studio that ships Telegram bots and Mini Apps end-to-end — including Stars checkout flows like this one — BotCreator builds and maintains them. For the underlying method reference used in this article, see their Telegram Bot API notes.