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

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.

New articles on Telegram

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