Telegram bot payments with Stripe: the production integration guide
How to wire Stripe into a Telegram bot the right way — invoice flows, webhook idempotency, refund handling, and the parts the docs don't tell you.
Telegram natively supports payments through Stripe, Paddle, and other providers — but the on-ramp is undocumented in the parts that matter most. The official docs cover the happy path: send an invoice, the user pays, you receive a successful_payment update. They do not cover refunds, subscriptions, dispute handling, idempotent webhook processing across both Telegram's update stream and Stripe's webhook stream, or what happens when the bot restarts mid-checkout. This is the playbook we use to ship Telegram bot payments to production for real businesses.
The two integration paths (you need to pick)
Telegram offers two payment models. The choice is not interchangeable.
Telegram Payments 2.0 (recommended for most cases). Telegram acts as the checkout UI. You call sendInvoice, Telegram presents the payment sheet, your user enters card details in Telegram, and Telegram charges through your connected Stripe account. You get a successful_payment update via the bot. Stripe also fires its own charge.succeeded and payment_intent.succeeded webhooks.
Direct Stripe Checkout link. You send the user a Stripe Checkout URL. They open it in their browser, pay, get redirected to a success page you control. Telegram has no part in the payment flow other than delivering the link.
Path 1 has the smoothest UX (everything happens in Telegram) but only supports one-off payments — no subscriptions, no usage-based billing.
Path 2 is uglier (the user leaves Telegram) but supports the full Stripe surface — subscriptions, customer portals, Stripe-managed retries.
For a tip jar, content unlock, or one-time product, use Path 1. For a SaaS subscription with monthly billing, use Path 2.
Path 1: Telegram-native invoice flow
The minimum viable code:
// src/bots/telegram/commands/buy.ts
import { Telegraf } from "telegraf";
const bot = new Telegraf(process.env.TELEGRAM_BOT_TOKEN!);
bot.command("buy", async (ctx) => {
await ctx.replyWithInvoice({
title: "Premium Access",
description: "30 days of premium features",
payload: JSON.stringify({
userId: ctx.from.id,
sku: "premium_30d",
nonce: crypto.randomUUID(),
}),
provider_token: process.env.TELEGRAM_STRIPE_PROVIDER_TOKEN!,
currency: "USD",
prices: [{ label: "Premium Access (30d)", amount: 990 }], // $9.90
});
});
// pre_checkout_query: last chance to reject before charging
bot.on("pre_checkout_query", async (ctx) => {
const payload = JSON.parse(ctx.preCheckoutQuery.invoice_payload);
const ok = await isStillAvailable(payload.sku);
await ctx.answerPreCheckoutQuery(ok, ok ? undefined : "Out of stock");
});
// successful_payment: charge went through, grant the entitlement
bot.on("message", async (ctx, next) => {
const msg = ctx.message;
if (!("successful_payment" in msg)) return next?.();
const payload = JSON.parse(msg.successful_payment.invoice_payload);
await grantEntitlement(payload);
await ctx.reply("✓ Premium unlocked. Enjoy.");
});
bot.launch();This works. It is also wrong for production. Two bugs hiding in plain sight.
Bug #1 — the bot restart during pre_checkout_query
The pre_checkout_query event must be answered within 10 seconds. If your bot is restarting, deploying, or otherwise unavailable, Telegram aborts the payment. Worse, Telegram does not redeliver pre_checkout_query — the user just sees a failure.
Mitigation: keep pre_checkout_query handling as fast as possible. No external API calls. No database queries that can hang. The "is this still available" check should be a single indexed lookup. If you genuinely need to do slow work, do it asynchronously and pre-warm the answer at sendInvoice time.
Bug #2 — duplicate successful_payment processing
Telegram redelivers updates on bot restart if you haven't acknowledged them yet. The same successful_payment can land in your handler twice. Without idempotency, the user gets two entitlements for one payment.
The fix is the same idempotency pattern from webhook idempotency is the bug most teams ship, keyed on provider_payment_charge_id from the successful_payment object:
async function handleSuccessfulPayment(msg: SuccessfulPaymentMessage) {
const chargeId = msg.successful_payment.provider_payment_charge_id;
const result = await db.transaction(async (tx) => {
const inserted = await tx.execute(
`insert into processed_events (id, source, type, payload)
values ($1, 'telegram', 'successful_payment', $2)
on conflict (id) do nothing
returning id`,
[chargeId, msg],
);
if (inserted.length === 0) return "duplicate";
await grantEntitlement(JSON.parse(msg.successful_payment.invoice_payload));
return "processed";
});
return result;
}The Stripe webhook problem
When using Path 1, Stripe also sends webhooks to your endpoint for the same charge. You now have two event streams for one payment: Telegram's successful_payment update and Stripe's charge.succeeded webhook. They can arrive in either order.
Two options.
Option A — primary on Telegram, ignore Stripe. Your provider_token ties the Stripe charge to your account. Stripe charges. Telegram tells you about it. You grant the entitlement. You ignore Stripe's webhook for these charges. Simpler.
Option B — primary on Stripe, ignore Telegram. You react only to Stripe webhooks. Your payment_intent metadata carries the Telegram chat id, so you can DM the user from the Stripe webhook handler. More work but better fits a system that already has Stripe-driven billing.
For pure Telegram-native bots, Option A. For bots that share infrastructure with a SaaS, Option B.
Path 2: Stripe Checkout link inside Telegram
If you need subscriptions, you can't use Path 1. Use Stripe Checkout instead:
bot.command("subscribe", async (ctx) => {
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: process.env.STRIPE_PRICE_PREMIUM_MONTHLY!, quantity: 1 }],
success_url: `https://yourapp.com/telegram/success?token={CHECKOUT_SESSION_ID}`,
cancel_url: `https://yourapp.com/telegram/cancel`,
client_reference_id: String(ctx.from.id),
subscription_data: {
metadata: { telegram_user_id: String(ctx.from.id) },
},
});
await ctx.reply(
`Subscribe to Premium: ${session.url}`,
{ reply_markup: { inline_keyboard: [[{ text: "Subscribe →", url: session.url! }]] } },
);
});The Telegram chat id is in client_reference_id and the subscription metadata. Your Stripe webhook handler reads it and DMs the user via the Bot API on customer.subscription.created.
// In your Stripe webhook handler
case "customer.subscription.created": {
const sub = event.data.object as Stripe.Subscription;
const telegramUserId = sub.metadata.telegram_user_id;
if (telegramUserId) {
await telegramBot.telegram.sendMessage(
Number(telegramUserId),
"✓ Subscription active. /premium to see commands.",
);
}
await upsertSubscription(sub);
break;
}Refunds — the gotcha nobody mentions
Telegram-native payments (Path 1) support refunds only via your Stripe dashboard. Refunding fires Stripe's charge.refunded webhook — but Telegram does not deliver a corresponding update to your bot.
If you don't handle Stripe's refund webhook, the user's entitlement stays granted after the refund. Customer-facing chaos.
Always handle charge.refunded even in Path 1:
case "charge.refunded": {
const charge = event.data.object as Stripe.Charge;
// Look up the entitlement by the Telegram charge id we stored.
await revokeEntitlementByChargeId(charge.id);
// Optionally DM the user via Telegram
const userId = await getUserIdByChargeId(charge.id);
if (userId) {
await telegramBot.telegram.sendMessage(
Number(userId),
"Your payment was refunded. Access removed.",
);
}
break;
}We had to fix this in production three times across customer integrations. It's not in the Telegram docs anywhere. Now you know.
Test mode
Telegram's test environment uses a separate provider_token prefixed with TEST:. Stripe's test mode uses test keys. The two are independent — you need a test provider_token from @BotFather and a test Stripe API key.
For local development:
TELEGRAM_BOT_TOKEN=...
TELEGRAM_STRIPE_PROVIDER_TOKEN=TEST:284685063:...
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...Use stripe listen --forward-to localhost:3000/api/stripe/webhook to forward Stripe webhooks to your local server. Use ngrok or a similar tunnel to expose your bot to Telegram for setWebhook.
The deployment checklist
Before flipping the switch in production:
- [ ] Bot webhook URL set via
setWebhook(not polling) for reliability under load - [ ]
processed_eventstable created and indexed - [ ] Stripe webhook endpoint registered with the correct signing secret
- [ ]
charge.refundedhandler in place - [ ]
payment_intent.payment_failedhandler if doing card-based subscriptions - [ ] Pre-checkout availability check is <100ms
- [ ] Idempotency on every
successful_paymentand Stripe webhook - [ ] Sentry or equivalent on both bot errors and webhook errors
- [ ] Hard rate-limit on the
/buycommand to prevent abuse
Building a Telegram bot with payments?
We've shipped Telegram bots with Stripe, refund flows, subscription portals, and abuse-resistant rate limiting.
FAQ
Can I use Telegram payments without Stripe?
Yes — Telegram supports Stripe, Paddle, Yandex.Money, Tranzzo, and several regional providers. The architecture is identical; only the provider_token changes. For most builders Stripe is the easiest globally.
Does Telegram take a cut?
Telegram does not. You pay only the Stripe processing fee. This makes Telegram payments meaningfully cheaper than App Store / Play Store for digital goods if your customer base is on Telegram.
What about Telegram Stars / TON?
Stars are Telegram's internal currency for digital goods in the Telegram Mini App ecosystem. Different system from card payments. If you're building a Telegram Mini App, Stars is the right call for in-app purchases. For straight bot commerce, Stripe via Path 1.
Can I do subscriptions through Telegram Payments natively?
No. Telegram's native payments are one-off only. Subscriptions require Path 2 (Stripe Checkout link).
What's the minimum charge amount?
Stripe enforces a $0.50 minimum in USD (varies by currency). Telegram's invoice flow respects whatever Stripe enforces.
Can users pay with Apple Pay / Google Pay?
Yes — Telegram surfaces Apple/Google Pay automatically when the user's device supports it. You don't need to configure anything bot-side.
How do refunds work?
Initiate from the Stripe dashboard or API. Stripe fires charge.refunded. Your webhook handler revokes the entitlement (Telegram does not deliver a refund update to the bot itself).
What happens if my bot is offline when payment completes?
Telegram queues updates and redelivers when the bot comes back online. Idempotency on provider_payment_charge_id prevents double-processing.