DEV Community

pante5ter
pante5ter

Posted on

Building a Telegram bot that takes payments: what actually matters

"Just add payments to the bot" sounds like a one-liner. In practice, the happy path is the easy 20% — the other 80% is the edge cases that decide whether you actually get paid and keep customers. Here's what actually matters when a Telegram bot handles money, learned from shipping a few of them.

Two very different ways to charge

There are two models, and picking the wrong one causes most of the pain:

  • Telegram's native payments (sendInvoice + a provider token like Stripe). The whole flow stays inside Telegram, the UX is excellent, and you get a clean successful_payment update. Great for digital goods and simple checkouts.
  • External payment link / your own gateway. You generate a link, the user pays on a web page, and your backend gets a webhook. More flexible (subscriptions, local providers, complex carts) but you own more of the plumbing.

For most "sell a product or a subscription" bots, native payments are the right default. Reach for external only when the provider or business model forces it.

The pre-checkout step is not optional

With native payments, Telegram sends a pre_checkout_query and you have ~10 seconds to answer it. If you don't answerPreCheckoutQuery(ok=true) in time, the payment fails — even though the user did nothing wrong.

This is where you do final validation: is the item still in stock, is the price still valid, is the user allowed to buy. Approve only if everything checks out. Skipping real validation here is how people sell things they can't deliver.

Treat money events as untrusted until verified

Two rules that save you from fraud and angry customers:

  1. Verify webhook signatures. Anyone can POST to your webhook URL. If you act on an unsigned "payment succeeded" call, you'll ship goods for payments that never happened. Always verify the provider's signature before trusting the event.
  2. Make payment handling idempotent. Networks retry. You will receive the same successful_payment / webhook twice. Store a unique payment ID and ignore duplicates, or you'll deliver (or refund) twice.

The database is the source of truth, not the chat

A Telegram message can be missed, edited, or arrive out of order. Don't drive business logic off the conversation state alone. Persist every order with an explicit status — pending → paid → fulfilled → refunded — and let the bot read from that. When a user writes "where's my order?", you answer from the database, not from memory.

Plan for refunds and failures from day one

The unglamorous parts customers judge you on:

  • A clear message when a payment fails (and an easy retry), not silence.
  • A defined refund path — Telegram supports refunds for native payments; wire it up before you need it.
  • An admin view: who paid, what's unfulfilled, what errored. A bot that takes money but gives the owner no visibility is a support nightmare.

A sane minimum architecture

Bot (webhook mode, not long polling for production) → a small backend that validates and writes to a database → payment provider webhooks landing on a separate, signature-verified endpoint. Keep secrets in env vars, log every money event, and alert on failed payments. That's it — boring on purpose, because boring is what you want around money.


None of this is hard individually; the value is doing all of it so nothing silently breaks once real money flows. I build Telegram bots with payments, databases and admin panels as a freelancer — you can see examples at vengstudio.online. Happy to answer implementation questions in the comments.

Top comments (3)

Collapse
 
harjjotsinghh profile image
Harjot Singh

"What actually matters" is the right lens, because the Telegram bot tutorial is everywhere but the payments part is where people get burned. The stuff that actually matters with bot payments is the unglamorous reliability: idempotent payment handling (the user's network drops mid-confirm and retries - you must not double-charge), verifying the payment webhook is genuinely from the provider (not a spoofed "paid!" message), and reconciling bot state with payment state so a successful charge always unlocks the thing. The happy path is trivial; the money-correctness edge cases are the whole job.

This is the same lesson that shows up anywhere money meets software: payments aren't a feature, they're a correctness-critical subsystem where "mostly works" means "occasionally charges people wrong." It's exactly why I treat billing as a verified, idempotent default in Moonshift (a multi-agent pipeline that ships a prompt to a deployed SaaS) rather than a quick integration - the edge cases you're describing are the norm. Genuinely useful, "what actually matters" posts beat tutorials. Which payment edge case bit you hardest - double-charge on retry, webhook verification, or state reconciliation? Those three seem to get everyone.

Collapse
 
harjjotsinghh profile image
Harjot Singh

Telegram bot payments is a great example of "the demo is trivial, the real thing is the edge cases." Sending a message is easy; handling the payment lifecycle, pending, failed, refunds, the provider webhook that fires twice or not at all, is where it gets real, and it's actual money so the bugs hurt. Your "what actually matters" framing is right: idempotency and reconciling against the provider beat trusting the happy path every time. That billing-edge-case wiring is exactly the boring-but-critical layer I automate in Moonshift. Which payment edge case bit you hardest, double-charges or webhook reliability?

Collapse
 
pante5ter profile image
pante5ter

100% — "mostly works" meaning "occasionally charges people wrong" is the whole point. Idempotency keyed on the provider's payment ID plus signature verification on the webhook are the two non-negotiables; everything else is recoverable. The state-reconciliation piece is underrated — I keep an explicit order status machine as the source of truth so a confirmed charge always maps to a delivered thing, even when the chat update is lost. Treating billing as a verified, idempotent default rather than a quick integration (like you do in Moonshift) is exactly the right instinct.