What we build and what we skip
This tutorial shows a minimal PHP path for accepting Telegram Stars through the Bot API. You will:
- Send an invoice with
sendInvoicein currencyXTR. - Handle
pre_checkout_queryand callanswerPreCheckoutQueryin time. - Receive
message.successful_paymentand verify charge id, Stars amount, and your invoice payload. - Avoid common mistakes: answering too late, treating Stars as bank cash, and trusting client-side totals.
We do not cover KYC, Fragment withdrawals, or tax. This is the integration layer, not the finance layer.
1. Sending an invoice in XTR
Stars invoices use currency XTR. Omit provider_token — Telegram rejects it for Stars. The user pays from their Stars balance. Your payload is an opaque string (up to 128 bytes) that ties successful_payment back to your order.
<?php
// send_invoice.php — start a Stars checkout
$token = getenv('BOT_TOKEN');
$chatId = (int) $update['message']['chat']['id'];
$payload = bin2hex(random_bytes(7)); // order key, ≤128 bytes
$stars = 1; // integer only
// Persist the order BEFORE sendInvoice
$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
];
$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);
}
Pin these rules:
prices[].amountis whole Stars — no fractions.payloadis a correlation key only. Do not put a tamperable price in it.- Persist first, send second. If
sendInvoicefails, mark the order failed so you never credit a charge you cannot look up.
2. Responding to pre_checkout_query
When the user confirms, Telegram sends pre_checkout_query with your invoice_payload and total_amount. You have about 10 seconds to call answerPreCheckoutQuery. Time out and Telegram cancels the payment. Re-check price and availability here — never trust keyboard callback data for money.
<?php
// pre_checkout.php
$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
&& empty($order['expires_at_gmt']); // optional TTL check
$body = [
'pre_checkout_query_id' => $pcqId,
'ok' => $ok ? 'true' : 'false',
];
if (!$ok) {
$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)) {
error_log('answerPreCheckoutQuery failed: '.$raw);
}
Checks that matter:
invoice_payloadmust resolve to a real order.total_amountmust match the Stars you stored.- Status must still be
pending. A second query for an already approved payload is replay — refuse it.
3. Verifying successful_payment
After the charge, your webhook gets message.successful_payment. That block includes:
currency === 'XTR'total_amount— Stars charged (integer)invoice_payload— the string you set on the invoicetelegram_payment_charge_id— unique charge id; store itprovider_payment_charge_id— empty for Stars; do not assume it exists
Treat this update as proof of the Stars receipt only. Stars are a Telegram-side balance, not a bank deposit. To inspect bot earnings, call getStarTransactions.
<?php
// successful_payment.php
$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') {
error_log('Unexpected currency on successful_payment: '.json_encode($sp));
http_response_code(200);
exit;
}
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);
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 on errors. Reconcile later.
}
http_response_code(200);
Verify all of this:
currency === 'XTR'invoice_payloadmaps to a pending ordertotal_amountmatches expected Starstelegram_payment_charge_idis unique in your ledger (idempotency key)
After crediting, you may send a short sendMessage confirmation.
4. Production notes
- Webhook secret. Pass
secret_tokeninsetWebhookand verifyX-Telegram-Bot-Api-Secret-Token. - Update idempotency. Persist
update_idand skip duplicates so webhooks cannot double-credit. - Refunds. Treat Stars refunds as their own events with charge ids and reconcile like any reversal.
- Logging. Keep the raw
successful_paymentJSON for at least 90 days. - Withdrawals. Stars are not fiat. Use
getStarTransactionsand Fragment for payout — do not promise users that payment equals your bank deposit.
5. Common pitfalls
- Returning HTTP 200 to the webhook without calling
answerPreCheckoutQuery— Telegram still cancels after the timeout. - Reading the price from a Buy button
callback_query. Money lives inpre_checkout_queryandsuccessful_payment. - Putting the price inside
payload. Keep it opaque; recompute on the server. - Including
provider_tokenforXTR—sendInvoicefails. - Treating Stars as banked cash.
- Returning errors on
successful_paymentforever. Log, return200, and guard with a uniquetelegram_payment_charge_id.
6. End-to-end shape
- User taps Buy. You
answerCallbackQueryand callsendInvoicewithcurrency=XTR. - Telegram shows the Stars sheet. On confirm it sends
pre_checkout_query; you answer only after re-checking the order. - On charge, Telegram sends
successful_payment. You verify currency, amount, payload, and charge id, credit once, then confirm withsendMessage.
Keep money facts in two places only: the invoice_payload you generated, and the successful_payment Telegram sends back.
Building Stars checkout into a production bot or Mini App? botservice.biz is a soft place to start.