Telegram Stars payments are easy to wire up and awkward to test. Every real test costs real Stars, the interesting failures (a second charge, a redelivered update, a refund you didn't start) are hard to trigger on purpose, and you don't want a staging bot quietly taking money from your teammates.

Here are three layers of testing, from cheapest to most realistic. Most bugs should die in the first one.

Layer 1: fake the updates in your own tests

Your bot never talks to a card or a wallet. It receives two updates (pre_checkout_query, then a message with successful_payment) and makes one or two calls back (answerPreCheckoutQuery, maybe refundStarPayment). That means you can test almost the whole flow by feeding your handler hand-built updates and recording the calls it makes.

// A fake Bot API: records calls instead of sending them function fakeTelegram() { const calls = []; return { calls, tg: async (method, body) => { calls.push({ method, body }); return true; }, }; } const preCheckout = (order, overrides = {}) => ({ id: "pcq-1", from: { id: order.userId }, currency: "XTR", total_amount: order.stars, invoice_payload: order.id, ...overrides, }); const paid = (order, chargeId = "charge-1") => ({ from: { id: order.userId }, successful_payment: { currency: "XTR", total_amount: order.stars, invoice_payload: order.id, telegram_payment_charge_id: chargeId, }, }); 

Now the cases that are hard to reproduce by hand become one-liners:

test("a redelivered successful_payment grants once", async () => { const { tg, calls } = fakeTelegram(); const order = await createOrder({ stars: 50 }); await onSuccessfulPayment(paid(order), tg); await onSuccessfulPayment(paid(order), tg); // same charge id again expect(await countGrants(order.userId)).toBe(1); expect(calls.filter((c) => c.method === "refundStarPayment")).toHaveLength(0); }); test("a second charge for one order is refunded", async () => { const { tg, calls } = fakeTelegram(); const order = await createOrder({ stars: 50 }); await onSuccessfulPayment(paid(order, "charge-1"), tg); await onSuccessfulPayment(paid(order, "charge-2"), tg); expect(calls).toContainEqual({ method: "refundStarPayment", body: { user_id: order.userId, telegram_payment_charge_id: "charge-2" }, }); }); test("pre-checkout refuses a changed price", async () => { const { tg, calls } = fakeTelegram(); const order = await createOrder({ stars: 50 }); await onPreCheckout(preCheckout(order, { total_amount: 40 }), tg); expect(calls[0].body.ok).toBe(false); }); 

Worth covering at this layer:

  • pre-checkout for an expired order, someone else's order, an already-paid order and a wrong amount;
  • two pre-checkouts for the same order at once (run them with Promise.all against a real database, so your row lock is actually exercised);
  • successful_payment with an unknown payload, a redelivery, and a second charge;
  • a refunded_payment you started (should be a no-op) and one you didn't (should alert you);
  • your handler throwing halfway: does the order end up in a state your reconciler can fix?

Use a real database here, not a mock. Most payment bugs are about state and concurrency, and a mocked repository hides exactly those.

Layer 2: Telegram's test environment

For the parts you can't fake (the payment sheet, openInvoice callbacks in a Mini App, what the user actually sees), Telegram runs a separate test environment. Its payments docs say you can "freely test" Stars payments there.

It is completely separate from production, so you need a new user account and a new bot:

  • iOS: tap the Settings icon 10 times, then Accounts ‚Üí Login to another account ‚Üí Test.
  • Telegram Desktop: open Settings, then Shift + Alt + right-click "Add Account" and pick "Test Server".
  • macOS app: click the Settings icon 10 times to open the Debug Menu, then ‚åò + click "Add Account".

Create the bot with @botfather inside that test account. Then point your code at the test Bot API, which only differs by a /test segment:

const BASE = process.env.TG_TEST ? `https://api.telegram.org/bot${process.env.BOT_TOKEN}/test` : `https://api.telegram.org/bot${process.env.BOT_TOKEN}`; const tg = (method, body) => fetch(`${BASE}/${method}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }).then((r) => r.json()); 

Two things that make the test environment nicer than production for this:

  • HTTP is allowed. Mini App and login URLs may use plain http:// without TLS, so you can point the bot at your laptop on the local network without setting up certificates.
  • It's isolated. Nobody real can stumble into your staging bot.

And two things to watch:

  • Flood limits aren't higher there, and the docs say they can be stricter. Keep your retry logic on.
  • initData is signed with a different key. If you validate Mini App initData with Telegram's Ed25519 public key (the third-party validation method), the test environment has its own key, listed in the Mini Apps docs. If you use the bot-token HMAC method, nothing changes, since the test bot has its own token anyway.

Layer 3: one real Star in production, then refund it

Some things only exist in production: your real bot, your real webhook, your real database. For a final smoke test after a deploy:

  1. Make a hidden product that costs 1 Star, visible only to your own account.
  2. Buy it from your own Telegram account.
  3. Check the order went through every state you expect, and the item was granted.
  4. Refund it with refundStarPayment, using the telegram_payment_charge_id from the order.
await tg("refundStarPayment", { user_id: MY_TELEGRAM_ID, telegram_payment_charge_id: order.chargeId, }); 

The refund returns the Star to your balance, so the whole test costs nothing, and it also proves your refund path works end to end. Your bot should then receive a refunded_payment echo. Check that your handler sees the order is already refunded and does nothing.

A short checklist

  • Every edge case of pre-checkout and successful_payment has a unit or integration test with fake updates.
  • Concurrency tests run against a real database.
  • A test-environment bot exists for UI and Mini App flows, with the /test Bot API base switched by config.
  • Production has a 1-Star hidden product for smoke tests, refunded after each run.
  • The refunded_payment echo of your own refund is a no-op.

One last thing

When testers and early users try your payment flow, someone will ask where to get Stars without buying them. It's a fair question, and the search results are full of "generator" bots that are phishing. We keep a guide to getting Telegram Stars for free the legitimate way that you can send them to.

How do you test payments in your bot? I'd like to compare notes in the comments.