Most Telegram bots that charge money end up with two kinds of users: free users who get a taste, and paid users who get more. The simplest way to draw that line is a rate limit: free users get, say, 5 requests an hour, paid users get 100.
This post shows how to do it cleanly in python-telegram-bot (v21/v22) with one "gatekeeper" handler that runs before everything else, so you don't sprinkle limit checks through every command.
The idea: a handler that runs first
In python-telegram-bot, handlers live in numbered groups. Groups are processed in ascending order, and within a group only the first matching handler runs. If any handler raises ApplicationHandlerStop, no further handlers run for that update, even in other groups.
So we register a TypeHandler(Update, ...) in group -1. It sees every update before your normal handlers in group 0. If the user is over their limit, it replies once and raises ApplicationHandlerStop. Otherwise it returns and the update flows through as usual.
Step 1: a sliding-window limiter
A sliding window keeps the timestamps of each user's recent requests and counts how many fall inside the window. It's fairer than a fixed "reset on the hour" counter, because a user can't burst 10 requests across 12:59 and 13:00.
# limiter.py
import time
from collections import defaultdict, deque
class SlidingWindowLimiter:
def __init__(self, window_seconds: int):
self.window = window_seconds
self.hits: dict[int, deque[float]] = defaultdict(deque)
def check(self, user_id: int, limit: int) -> tuple[bool, int]:
"""Return (allowed, seconds_until_next_slot)."""
now = time.monotonic()
q = self.hits[user_id]
# Drop timestamps that have left the window
while q and now - q[0] >= self.window:
q.popleft()
if len(q) < limit:
q.append(now)
return True, 0
retry_after = int(self.window - (now - q[0])) + 1
return False, retry_after
time.monotonic() is used instead of time.time() so a system clock change can't break the math.
Step 2: decide who is paid
Your source of truth for "paid" might be a database, a license key table, or a webhook from your payment provider. For this tutorial it's a function you can swap out:
# tiers.py
PAID_USER_IDS: set[int] = set() # fill from your DB on startup
TIERS = {
"free": 5, # requests per window
"paid": 100,
}
def tier_for(user_id: int) -> str:
return "paid" if user_id in PAID_USER_IDS else "free"
Keep this lookup fast. It runs on every single update, so hitting a remote API here will slow your whole bot down. Cache it in memory and refresh it when a payment event arrives.
Step 3: the gatekeeper handler
# bot.py
import logging
import os
from telegram import Update
from telegram.ext import (
Application,
ApplicationHandlerStop,
CommandHandler,
ContextTypes,
TypeHandler,
)
from limiter import SlidingWindowLimiter
from tiers import TIERS, tier_for
logging.basicConfig(level=logging.INFO)
limiter = SlidingWindowLimiter(window_seconds=3600)
async def gatekeeper(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
user = update.effective_user
if user is None: # channel posts, some service updates
return
tier = tier_for(user.id)
allowed, retry_after = limiter.check(user.id, TIERS[tier])
if allowed:
return # let the update continue to group 0
# Only answer once per blocked burst, so we don't spam the user
warned = context.user_data.get("rl_warned_until", 0)
if update.effective_message and retry_after > 0 and warned < retry_after:
minutes = max(1, retry_after // 60)
text = (
f"You've hit the free limit of {TIERS['free']} requests per hour. "
f"Try again in about {minutes} min, or upgrade with /upgrade."
if tier == "free"
else f"Slow down a little, try again in about {minutes} min."
)
await update.effective_message.reply_text(text)
context.user_data["rl_warned_until"] = retry_after
raise ApplicationHandlerStop
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
context.user_data.pop("rl_warned_until", None)
await update.message.reply_text("Hi! Send /price BTC to try me.")
async def price(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
symbol = (context.args[0] if context.args else "BTC").upper()
# ... call your real API here ...
await update.message.reply_text(f"{symbol}: (your data here)")
async def upgrade(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text("Link to your checkout page goes here.")
def main() -> None:
app = Application.builder().token(os.environ["BOT_TOKEN"]).build()
# Group -1 runs before the default group 0
app.add_handler(TypeHandler(Update, gatekeeper), group=-1)
app.add_handler(CommandHandler("start", start))
app.add_handler(CommandHandler("price", price))
app.add_handler(CommandHandler("upgrade", upgrade))
app.run_polling(allowed_updates=Update.ALL_TYPES)
if __name__ == "__main__":
main()
A few details worth calling out:
-
/upgradeis also rate-limited here. You probably want blocked users to still reach it. The easy fix: at the top ofgatekeeper, return early ifupdate.effective_message.textstarts with/upgradeor/start. - The warning is sent once per blocked stretch, not on every message, so a frustrated user mashing buttons doesn't get ten identical replies.
-
ApplicationHandlerStophas no effect in non-blocking handlers. Don't register the gatekeeper withblock=False, or the stop is ignored and every update gets through. ## Step 4: survive restarts and multiple workers with Redis
The in-memory limiter resets every time the process restarts and doesn't work if you run more than one instance. Redis fixes both. A sorted set per user gives you the same sliding window:
# redis_limiter.py
import time
import uuid
import redis.asyncio as redis
r = redis.from_url("redis://localhost:6379/0")
async def check(user_id: int, limit: int, window: int = 3600) -> tuple[bool, int]:
key = f"rl:{user_id}"
now = time.time()
async with r.pipeline(transaction=True) as pipe:
pipe.zremrangebyscore(key, 0, now - window)
pipe.zcard(key)
_, count = await pipe.execute()
if count < limit:
await r.zadd(key, {f"{now}:{uuid.uuid4().hex}": now})
await r.expire(key, window)
return True, 0
oldest = await r.zrange(key, 0, 0, withscores=True)
retry_after = int(window - (now - oldest[0][1])) + 1 if oldest else window
return False, retry_after
Swap limiter.check(...) in the gatekeeper for await check(...) and you're done. The member name includes a random suffix so two requests in the same microsecond don't overwrite each other. There's a small race between the count and the add; for a bot's free tier that's fine. If you need it exact, move the logic into a Lua script so it runs atomically.
Step 5: test it in two minutes
- Set
TIERS["free"] = 2temporarily. - Send
/pricethree times. The third should get the limit message and nothing else. - Add your own Telegram user ID to
PAID_USER_IDS, restart, and repeat. All three go through.
You can find your user ID by logging update.effective_user.id in any handler.
Things to avoid
-
Don't rate-limit by chat ID in groups. In a group, every member shares one chat ID. Limit by
effective_user.idso one noisy member doesn't lock out everyone. -
Don't confuse this with
AIORateLimiter. PTB's built-inAIORateLimiterthrottles your bot's outgoing calls to stay under Telegram's flood limits. It doesn't limit what your users can do. You may want both. - Don't make the paid check slow. It runs on every update. Cache it.
Wrap-up
One TypeHandler in group -1, a sliding-window counter, and ApplicationHandlerStop give you a clean free/paid split without touching your command handlers. Start in memory, move to Redis when you scale past one process.
I built a kit that packages this (plus templates and a deploy guide): The Python Bot Monetization Kit
Top comments (1)
One more update type to let through besides /upgrade: the payment itself. With
allowed_updates=Update.ALL_TYPESthe gatekeeper also seespre_checkout_queryand thesuccessful_paymentmessage, and both have aneffective_user. So a free user who hit the limit, taps /upgrade and pays, gets the pre-checkout dropped byApplicationHandlerStop. Telegram wants an answer to that within 10 seconds, so the payment just fails, and the user never even gets the limit message becauseeffective_messageis None there.I ran it offline on PTB 22.8 with
process_updateand a limit of 2: three /price, then a pre_checkout_query, then a successful_payment. Without a bypass the last three all came back blocked. With this at the top of the gatekeeper the payment handlers ran:Related small thing:
update.effective_messageis None for a pre_checkout_query (and for inline queries, poll answers and so on), so theeffective_message.text.startswith(...)early return from the post would raise AttributeError there. Checkingmsg = update.effective_messageandmsg and msg.textfirst avoids that.