Accept Telegram Stars payments in PHP: sendInvoice, pre_checkout_query, and successful_payment server checks

What we build and what we skip

This tutorial shows a minimal PHP path for accepting Telegram Stars through the Bot API. You will:

  1. Send an invoice with sendInvoice in currency XTR.
  2. Handle pre_checkout_query and call answerPreCheckoutQuery in time.
  3. Receive message.successful_payment and verify charge id, Stars amount, and your invoice payload.
  4. 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[].amount is whole Stars — no fractions.
  • payload is a correlation key only. Do not put a tamperable price in it.
  • Persist first, send second. If sendInvoice fails, 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_payload must resolve to a real order.
  • total_amount must 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 invoice
  • telegram_payment_charge_id — unique charge id; store it
  • provider_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:

  1. currency === 'XTR'
  2. invoice_payload maps to a pending order
  3. total_amount matches expected Stars
  4. telegram_payment_charge_id is unique in your ledger (idempotency key)

After crediting, you may send a short sendMessage confirmation.

4. Production notes

  • Webhook secret. Pass secret_token in setWebhook and verify X-Telegram-Bot-Api-Secret-Token.
  • Update idempotency. Persist update_id and 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_payment JSON for at least 90 days.
  • Withdrawals. Stars are not fiat. Use getStarTransactions and 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 in pre_checkout_query and successful_payment.
  • Putting the price inside payload. Keep it opaque; recompute on the server.
  • Including provider_token for XTR — sendInvoice fails.
  • Treating Stars as banked cash.
  • Returning errors on successful_payment forever. Log, return 200, and guard with a unique telegram_payment_charge_id.

6. End-to-end shape

  1. User taps Buy. You answerCallbackQuery and call sendInvoice with currency=XTR.
  2. Telegram shows the Stars sheet. On confirm it sends pre_checkout_query; you answer only after re-checking the order.
  3. On charge, Telegram sends successful_payment. You verify currency, amount, payload, and charge id, credit once, then confirm with sendMessage.

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.

New articles on Telegram

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