What Are Telegram Stars
Telegram Stars are Telegram’s native digital currency for in-app purchases. Users buy Stars through the App Store or Google Play, then spend them inside bots and Mini Apps. The bot developer receives Stars, which can be withdrawn as Toncoins or kept as balance for Telegram’s ad platform.
The critical detail: Stars transactions go through Telegram’s own payment infrastructure. No Stripe integration. No payment processor onboarding. No PCI compliance headaches. You call the Bot API, the user taps a button, Telegram handles the payment UI, and your bot receives a confirmation. The entire flow lives inside Telegram.
One Star costs roughly $0.02 USD, though the exact rate fluctuates. The minimum invoice amount is 1 Star. Telegram takes a cut — Apple and Google take their platform fees from the initial Star purchase, and Telegram retains a share on withdrawal. The net to the developer varies, but the friction reduction is worth it. A user who already has Stars in their account is one tap away from paying you.
Why Stars Matter for Bot Monetization
Before Stars, monetizing a Telegram bot meant integrating an external payment provider — Stripe, YooKassa, or one of the other supported gateways. That required a merchant account, KYC verification, webhook configuration, and handling the full payment lifecycle outside of Telegram. Most solo developers never bothered.
Stars eliminate all of that. The payment happens natively. The user never leaves the chat. There is no redirect to an external checkout page, no card form, no 3D Secure challenge. The conversion funnel is as short as it gets: bot sends invoice, user taps “Pay,” done.
For bots serving Telegram-native audiences — people who already live inside the app — this is the lowest-friction payment method available. Users accumulate Stars from gifts, Premium subscriptions, and direct purchases. Spending them feels like spending in-app currency, not real money. That psychological distance works in your favor.
Setting Up Payment Capability
Stars payments require no special provider configuration in BotFather. Unlike traditional Telegram payments where you need to connect a payment provider token, Stars work out of the box with any bot.
The only requirement: your bot must be able to receive updates for pre_checkout_query and successful_payment. In grammY, that means registering handlers for both event types. If you are using webhooks, ensure your webhook URL is set and responding. If you are using long polling, ensure you are polling for pre_checkout_query updates.
In your grammY bot setup, make sure you are not filtering out these update types:
import { Bot } from "grammy";
const bot = new Bot(process.env.BOT_TOKEN);
// These handlers must exist before you send any invoices.
// Register them during bot initialization, not lazily.
bot.on("pre_checkout_query", async (ctx) => {
// We will fill this in below
});
bot.on("message:successful_payment", async (ctx) => {
// We will fill this in below
});
bot.start();
Creating Invoices
There are two ways to create a Stars invoice: sendInvoice for sending directly into a chat, and createInvoiceLink for generating a shareable payment link. Both accept the same core parameters.
sendInvoice
Use sendInvoice when the user is already in a conversation with your bot and you want to present the payment inline:
bot.command("buy", async (ctx) => {
await ctx.api.sendInvoice(
ctx.chat.id,
"OCR Scan Credit", // title
"One receipt scan using Claude Vision", // description
"ocr_credit_1", // payload — your internal reference
"XTR", // currency — always "XTR" for Stars
[{ label: "1 Scan Credit", amount: 5 }], // prices — 5 Stars
);
});
Key points about the parameters:
- Currency is always
"XTR"for Stars. This is not optional and not configurable. - Payload is a string you define. It comes back to you in the
pre_checkout_queryandsuccessful_paymentevents. Use it to identify what the user is buying. Maximum 128 bytes. - Prices is an array, but for Stars it should contain a single item. The
amountis in Stars (not cents, not subunits — just Stars). - Provider token is not needed. Do not pass a
provider_tokenparameter. Stars invoices must omit it or pass an empty string.
createInvoiceLink
Use createInvoiceLink when you need a URL that can be shared, embedded in an inline keyboard, or sent outside of the bot context:
const link = await bot.api.createInvoiceLink(
"Pro Feature Pack",
"Unlock all premium features for this bot",
"pro_pack_v1",
"XTR",
[{ label: "Pro Pack", amount: 50 }],
);
// Use the link in an inline keyboard
await ctx.reply("Upgrade to Pro:", {
reply_markup: {
inline_keyboard: [[
{ text: "50 Stars — Unlock Pro", url: link },
]],
},
});
Invoice links are reusable. Multiple users can pay through the same link. Each payment generates its own pre_checkout_query and successful_payment event with a unique telegram_payment_charge_id.
Handling pre_checkout_query
When a user taps “Pay” on your invoice, Telegram sends a pre_checkout_query to your bot. You must answer it within 10 seconds. If you do not respond, or respond after the deadline, the payment is cancelled automatically.
This is your last chance to validate the purchase before money moves. Check inventory, verify the user is eligible, confirm the price has not changed:
bot.on("pre_checkout_query", async (ctx) => {
const query = ctx.preCheckoutQuery;
const payload = query.invoice_payload;
const userId = query.from.id;
// Validate: does this product still exist at this price?
const product = await getProduct(payload);
if (!product) {
await ctx.answerPreCheckoutQuery(false, {
error_message: "This item is no longer available.",
});
return;
}
// Validate: has the user already purchased this?
const alreadyOwned = await checkOwnership(userId, payload);
if (alreadyOwned) {
await ctx.answerPreCheckoutQuery(false, {
error_message: "You already own this item.",
});
return;
}
// All good — approve the payment
await ctx.answerPreCheckoutQuery(true);
});
The 10-second deadline is strict. Do not make slow external API calls inside this handler. If you need to validate against a remote service, keep that service fast or pre-validate when the invoice is created. A database lookup is fine. A call to a third-party API with variable latency is risky.
If you approve the pre-checkout query, the payment proceeds. If you reject it, the user sees your error_message and can try again. Keep error messages short and actionable.
Processing successful_payment
After the user completes payment, Telegram sends a message update containing a successful_payment object. This is your confirmation that the money has moved:
bot.on("message:successful_payment", async (ctx) => {
const payment = ctx.message.successful_payment;
// payment.telegram_payment_charge_id — unique charge ID from Telegram
// payment.provider_payment_charge_id — empty for Stars
// payment.invoice_payload — your payload string from the invoice
// payment.total_amount — amount in Stars
// payment.currency — "XTR"
// 1. Record the payment
await db.insert("payments", {
telegramChargeId: payment.telegram_payment_charge_id,
userId: ctx.from.id,
payload: payment.invoice_payload,
amount: payment.total_amount,
currency: payment.currency,
createdAt: new Date().toISOString(),
});
// 2. Deliver the purchased item
await deliverProduct(ctx.from.id, payment.invoice_payload);
// 3. Confirm to the user
await ctx.reply(
"Payment received. Your credit has been added to your account."
);
});
Always record the telegram_payment_charge_id. You need it for refunds, dispute resolution, and idempotency checks. It is the canonical identifier for the transaction.
Refund Handling
Telegram provides a programmatic refund API for Stars payments via the refundStarPayment method. You need the user’s ID and the telegram_payment_charge_id from the original transaction:
async function refundPayment(
userId: number,
chargeId: string,
): Promise<boolean> {
try {
await bot.api.refundStarPayment(userId, chargeId);
// Mark the payment as refunded in your database
await db.update("payments", {
where: { telegramChargeId: chargeId },
data: { refundedAt: new Date().toISOString() },
});
return true;
} catch (error) {
// Refund may fail if already refunded or if charge is too old
console.error(`Refund failed for charge ${chargeId}:`, error);
return false;
}
}
Refunds are full — there is no partial refund mechanism for Stars. If a user paid 50 Stars, you refund 50 Stars. The Stars return to the user’s balance immediately.
You can trigger refunds programmatically (e.g., via an admin command in your bot) or in response to user requests. Build an admin interface early. You will need it.
Recurring vs One-Time Payments
Telegram Stars do not natively support recurring subscriptions. There is no “charge this user 10 Stars every month” API. Every payment requires explicit user action — they must tap a button and confirm each time.
This is actually a design advantage. Subscription fatigue is real. Users are increasingly hostile toward auto-renewing charges. A pay-as-you-go model sidesteps that entirely. The user pays when they want something, and they never worry about forgetting to cancel.
If you want recurring revenue, design around consumable credits rather than time-based access. Sell scan credits, generation tokens, export slots — units of value that get used up. When the user runs out, they buy more. The revenue recurs because the value recurs, not because a timer expired.
Production Pattern: FridgeKit Pay-As-You-Go
FridgeKit uses this exact model. Users buy OCR scan credits with Stars. Each receipt scan consumes one credit. No subscription, no monthly fee, no commitment. Scan a receipt when you need to, buy more credits when you run out.
The implementation follows a credit-ledger pattern:
// Check if user has credits before performing the action
async function consumeCredit(
userId: number,
action: string,
): Promise<boolean> {
const balance = await db.get(
"SELECT credits FROM user_balances WHERE user_id = ?",
userId,
);
if (!balance || balance.credits < 1) {
return false; // No credits — prompt purchase
}
// Deduct atomically
const result = await db.run(
`UPDATE user_balances
SET credits = credits - 1, updated_at = ?
WHERE user_id = ? AND credits >= 1`,
new Date().toISOString(),
userId,
);
if (result.changes === 0) {
return false; // Race condition — another request consumed the last credit
}
// Log the consumption
await db.run(
`INSERT INTO credit_log (user_id, action, delta, created_at)
VALUES (?, ?, -1, ?)`,
userId,
action,
new Date().toISOString(),
);
return true;
}
// After successful payment, add credits
bot.on("message:successful_payment", async (ctx) => {
const payment = ctx.message.successful_payment;
const credits = getCreditsForPayload(payment.invoice_payload);
await db.run(
`INSERT INTO user_balances (user_id, credits, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE
SET credits = credits + ?, updated_at = ?`,
ctx.from.id,
credits,
new Date().toISOString(),
credits,
new Date().toISOString(),
);
await db.run(
`INSERT INTO credit_log (user_id, action, delta, created_at)
VALUES (?, ?, ?, ?)`,
ctx.from.id,
"purchase",
credits,
new Date().toISOString(),
);
// Record the raw payment for refund capability
await db.run(
`INSERT INTO payments
(telegram_charge_id, user_id, payload, amount, credits, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
payment.telegram_payment_charge_id,
ctx.from.id,
payment.invoice_payload,
payment.total_amount,
credits,
new Date().toISOString(),
);
await ctx.reply(
`Payment received. ${credits} credit${credits === 1 ? "" : "s"} added to your account.`
);
});
The credit deduction uses a WHERE credits >= 1 guard to prevent negative balances from race conditions. Two simultaneous requests cannot both succeed if only one credit remains. This matters in production more than you expect.
Production Considerations
Idempotency
Telegram may deliver the successful_payment update more than once. Network issues, webhook retries, bot restarts — duplicates happen. Use telegram_payment_charge_id as a unique constraint in your payments table. If an insert fails due to a duplicate key, skip the credit delivery:
bot.on("message:successful_payment", async (ctx) => {
const payment = ctx.message.successful_payment;
const chargeId = payment.telegram_payment_charge_id;
// Attempt insert — will fail silently on duplicate
const inserted = await db.run(
`INSERT OR IGNORE INTO payments (telegram_charge_id, user_id, payload, amount, created_at)
VALUES (?, ?, ?, ?, ?)`,
chargeId,
ctx.from.id,
payment.invoice_payload,
payment.total_amount,
new Date().toISOString(),
);
if (inserted.changes === 0) {
// Already processed — do not double-deliver
return;
}
// First time seeing this payment — deliver the product
await deliverProduct(ctx.from.id, payment.invoice_payload);
await ctx.reply("Payment received. Your credits have been added.");
});
Error Handling
The pre_checkout_query handler must not throw. If it throws, your bot fails to respond within 10 seconds, and the payment is cancelled. Wrap everything in try-catch and default to rejecting the payment with a generic error rather than letting an exception propagate:
bot.on("pre_checkout_query", async (ctx) => {
try {
const isValid = await validatePurchase(
ctx.from.id,
ctx.preCheckoutQuery.invoice_payload,
);
if (!isValid) {
await ctx.answerPreCheckoutQuery(false, {
error_message: "Unable to process this purchase right now.",
});
return;
}
await ctx.answerPreCheckoutQuery(true);
} catch (error) {
console.error("pre_checkout_query handler failed:", error);
// Always respond — silence means cancellation
try {
await ctx.answerPreCheckoutQuery(false, {
error_message: "Something went wrong. Please try again.",
});
} catch {
// If even the rejection fails, we have a network issue.
// Nothing more we can do.
}
}
});
Storing Payment Records
At minimum, persist these fields for every successful payment:
telegram_payment_charge_id— primary key, required for refundsuser_id— who paidinvoice_payload— what they boughttotal_amount— how many Starscreated_at— when the payment was confirmedrefunded_at— null until refunded, if ever
This gives you everything needed for refund processing, revenue reporting, usage analytics, and dispute resolution. Add more columns as your product requires, but never store less than this.
Testing
Test Stars payments using Telegram’s test environment. Create a test bot via the test BotFather (accessible through Telegram’s test server), and issue test Star invoices. Test Stars are free and the flow is identical to production. Do not test payments in production with real Stars unless you plan to refund them immediately.
The Full Payment Flow
To summarize the complete lifecycle:
- Bot creates an invoice via
sendInvoiceorcreateInvoiceLinkwith currency"XTR". - User sees the payment card and taps “Pay.”
- Telegram sends
pre_checkout_queryto your bot. You validate and respond within 10 seconds. - If approved, Telegram processes the payment and sends
successful_payment. - Your bot records the transaction, delivers the product, and confirms to the user.
- If a refund is needed later, call
refundStarPaymentwith the charge ID.
Six steps. No external services. No webhook tunnels to a payment provider. No merchant dashboards. The entire payment lifecycle lives in your bot’s codebase and Telegram’s infrastructure.
We shipped this pattern in FridgeKit and it has been running in production since launch. If you are building a Telegram bot that needs monetization, Stars is the path of least resistance. Start with a single purchasable item, get the flow working end to end, and expand from there.
Building a bot and need payment integration? Reach out through our Telegram bot — we build these systems for a living.