If you sell anything digital inside Telegram (a premium feature, credits, a level pack), you sell it for Telegram Stars. Telegram requires Stars for digital goods in bots and Mini Apps, and the currency code is XTR.
The happy path takes about ten lines. Everything around it is what keeps your ledger right: double deliveries, a second charge, refunds you didn't start, a client that says "paid" when nothing was charged. Our small team runs a Telegram Mini App that takes Stars every day, and this is the flow we ended up with. The examples use plain fetch against the Bot API so they translate to any language.
const API = `https://api.telegram.org/bot${process.env.BOT_TOKEN}`; async function tg(method, body) { const res = await fetch(`${API}/${method}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); const json = await res.json(); if (!json.ok) throw new Error(`${method}: ${json.description}`); return json.result; } 1. Create the order before the invoice
Don't put the product in the invoice and hope. Write a pending order to your database first, and make the invoice payload that order's id:
async function startPurchase(userId, productId) { const product = await db.products.get(productId); const order = await db.orders.insert({ id: crypto.randomUUID(), userId, productId, stars: product.priceStars, status: "pending", expiresAt: Date.now() + 15 * 60_000, }); return order; } The payload (1 to 128 bytes, never shown to the user) comes back to you on every later step. When it is just an id, you never have to trust anything else that rides along with it.
2. Send the invoice
In a chat, use sendInvoice. In a Mini App, use createInvoiceLink and open the link from the client. The Stars rules: currency is "XTR", provider_token is an empty string, and prices holds exactly one item whose amount is a whole number of Stars.
const link = await tg("createInvoiceLink", { title: product.title, description: product.description, payload: order.id, provider_token: "", currency: "XTR", prices: [{ label: product.title, amount: order.stars }], }); On the Mini App side:
Telegram.WebApp.openInvoice(link, (status) => { // "paid" | "cancelled" | "failed" | "pending" if (status === "paid") pollOrderStatus(order.id); }); Treat "paid" as "go and ask the server", never as "grant the item". More on that in step 6.
Selling a monthly subscription instead? createInvoiceLink also takes subscription_period: 2592000 (30 days, the only period allowed) for Stars invoices. The rest of the flow is the same.
3. Answer pre_checkout_query, fast and strictly
When the user taps Pay, Telegram sends a pre_checkout_query and waits for your answer. You have 10 seconds. If you don't answer, the payment fails on the user's screen.
This is your last chance to say no before any Stars move, so check everything here:
async function onPreCheckout(q) { const order = await db.orders.getForUpdate(q.invoice_payload); // row lock const reason = !order ? "This order no longer exists." : order.userId !== q.from.id ? "This invoice belongs to someone else." : order.status !== "pending" ? "This order is already paid." : order.expiresAt < Date.now() ? "This invoice expired. Please start again." : q.currency !== "XTR" || q.total_amount !== order.stars ? "The price changed. Please start again." : null; if (reason) { return tg("answerPreCheckoutQuery", { pre_checkout_query_id: q.id, ok: false, error_message: reason }); } await db.orders.update(order.id, { status: "precheckout" }); return tg("answerPreCheckoutQuery", { pre_checkout_query_id: q.id, ok: true }); } Two things matter more than they look:
- Lock the row. One user with two devices (or one impatient double tap) can send two pre-checkouts for the same order. With
SELECT … FOR UPDATEthe second one waits and then sees the order is no longer pending. - Keep it cheap. No external calls, no slow queries. If something throws, still answer
ok: falsewith a polite message. A timeout is the worst answer you can give.
4. Grant the item on successful_payment, exactly once
The money has moved when your bot gets a message with successful_payment. Its telegram_payment_charge_id is the one identifier you must keep. You need it for refunds and it makes the handler idempotent.
async function onSuccessfulPayment(msg) { const p = msg.successful_payment; await db.transaction(async (tx) => { const order = await tx.orders.getForUpdate(p.invoice_payload); if (!order) return refund(msg.from.id, p.telegram_payment_charge_id, "unknown order"); if (order.status === "paid") { if (order.chargeId === p.telegram_payment_charge_id) return; // redelivery, ignore return refund(msg.from.id, p.telegram_payment_charge_id, "second charge for one order"); } await tx.orders.update(order.id, { status: "paid", chargeId: p.telegram_payment_charge_id }); await tx.inventory.grant(order.userId, order.productId); }); } Put a unique index on chargeId too. Updates can arrive twice (a webhook retry, a restart halfway through a batch), and the database is the cheapest place to say "seen it".
Notice the two refund branches. If a charge lands that your system can't match to an open order, the fair default is to give the Stars straight back and log loudly, not to keep them and sort it out later.
5. Refunds: yours and Telegram's
Refunding is one call:
async function refund(userId, chargeId, why) { log.warn(`refund ${chargeId}: ${why}`); await tg("refundStarPayment", { user_id: userId, telegram_payment_charge_id: chargeId }); } The part people miss: refunds can also come from Telegram, after a dispute or through support. Your bot then gets a message with refunded_payment (same payload and charge id). Handle it:
- If you started the refund, the order is already marked refunded. Ignore the echo.
- If you didn't, the user has their Stars back and still holds the item. Decide what that means for your product (revoke it, flag the account, or accept it), and at least alert yourself.
Also give users a clear way to ask about a payment from inside your bot. Telegram's terms expect one, and a quick human answer prevents most disputes.
6. Reconcile against Telegram's ledger
Your database is your view of the world. getStarTransactions is Telegram's. Read it on a schedule and compare:
async function* starTransactions() { for (let offset = 0; ; offset += 100) { const { transactions } = await tg("getStarTransactions", { offset, limit: 100 }); if (transactions.length === 0) return; yield* transactions; } } What this catches:
- Charges you never settled. Your process died between Telegram charging and your handler committing. The transaction is there, the order is still in
precheckout. Settle it or refund it. - "Paid" that never was. We have seen the client report an invoice as paid when Telegram's ledger had no charge for it. If a user is waiting on an order, the status endpoint can check the ledger and tell them it didn't go through, instead of leaving them on a spinner.
- Refunds you didn't hear about. A refund keeps its charge's id, so you can match it to the order.
One practical note: the docs describe the list as chronological, but don't build on its order. Read pages until you've gone past the window you care about, and treat "I hit my page limit" as "unknown", never as "not charged".
7. A checklist
- Order row first, invoice payload = order id.
-
XTR, emptyprovider_token, one price, whole Stars. - Pre-checkout: lock, check owner, status, expiry and amount, answer within 10 seconds, always answer.
- Grant only on
successful_payment, keyed ontelegram_payment_charge_id, unique index. - Refund anything you can't match. Handle
refunded_paymentyou didn't start. - Reconcile with
getStarTransactions. Never trust the client's "paid".
Your users will ask where Stars come from
Once you take Stars, some users will show up with an empty balance and ask how to get some without buying. That is a fair question, and the internet is full of "free Stars generator" bots that answer it with phishing. Point them at the real routes instead: a friend gifting Stars, a channel's Star giveaway, Telegram's affiliate programs, converting a received gift. We keep an up-to-date list of the ways to get Telegram Stars that actually work, with a 30-second check for spotting the scams.
If you've hit a Stars edge case that isn't here, I'd like to hear it in the comments.
