Home › Guides › Telegram group captcha
Guide · Telegram · PythonHow to add a captcha to your Telegram group with a Python bot
Spam accounts usually join a group and post within seconds. A simple button captcha stops most of them: every new member is muted until they tap “I’m human”. Whoever does not tap within two minutes is removed and can join again later. This guide builds exactly that in about 100 lines of Python with python-telegram-bot v22 – no database, no web server.
1. How the captcha works
- Telegram sends the bot a service message when someone joins (“new chat members”).
- The bot restricts the new member with
restrictChatMemberand no permissions, so they cannot write. - The bot posts a welcome message with an inline button. The button’s callback data contains the new member’s user id, so nobody else can press it for them.
- A one-off job is scheduled for 120 seconds later.
- If the member taps the button, the bot restores the group’s default permissions, cancels the job and deletes the captcha message.
- If the job runs first, the bot bans and immediately unbans the user. That removes them from the group without a permanent ban.
2. Create the bot and give it admin rights
- Open a chat with @BotFather in Telegram (check the blue verification mark), send
/newbot, choose a name and a username ending inbot. BotFather replies with a token like123456789:AA.... Treat it like a password. - Add the bot to your group, then make it an administrator with at least Ban users and Delete messages. Without these rights Telegram rejects the restrict and ban calls.
- The group should be a supergroup (all public groups and most groups with admins already are). Restricting members only works in supergroups.
Privacy mode in BotFather can stay on: bots in privacy mode still receive service messages such as “new member joined” and button presses.
3. Install Python packages
You need Python 3.10 or newer. The job queue (for the timeout) is an optional extra of python-telegram-bot, so install it with [job-queue]:
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install "python-telegram-bot[job-queue]==22.8"
Without the [job-queue] extra, context.job_queue is None and the bot crashes on the first join.
4. The complete code
Save this as captcha_bot.py:
"""Minimal captcha for Telegram groups with python-telegram-bot v22.
New members are muted until they press a button. If they do not press it
within CAPTCHA_SECONDS, they are removed from the group (and can re-join).
"""
import logging
import os
from telegram import ChatPermissions, InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.error import TelegramError
from telegram.ext import (
Application,
CallbackQueryHandler,
ContextTypes,
MessageHandler,
filters,
)
CAPTCHA_SECONDS = 120
logging.basicConfig(format="%(asctime)s %(levelname)s %(message)s", level=logging.INFO)
log = logging.getLogger("captcha")
def job_name(chat_id: int, user_id: int) -> str:
return f"captcha-{chat_id}-{user_id}"
async def on_new_members(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
message = update.effective_message
chat_id = message.chat.id
for member in message.new_chat_members:
if member.is_bot:
continue
try:
# Mute the new member: no messages, media, polls, etc.
await context.bot.restrict_chat_member(
chat_id, member.id, permissions=ChatPermissions.no_permissions()
)
except TelegramError as exc:
log.warning("Could not restrict %s (is the bot an admin?): %s", member.id, exc)
continue
button = InlineKeyboardButton("I'm human ✅", callback_data=f"captcha:{member.id}")
sent = await context.bot.send_message(
chat_id,
f"Welcome, {member.mention_html()}! Please tap the button within "
f"{CAPTCHA_SECONDS} seconds to be able to write in this group.",
reply_markup=InlineKeyboardMarkup([[button]]),
parse_mode="HTML",
)
context.job_queue.run_once(
kick_if_unverified,
CAPTCHA_SECONDS,
chat_id=chat_id,
user_id=member.id,
data=sent.message_id,
name=job_name(chat_id, member.id),
)
async def on_button(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.callback_query
expected_user = int(query.data.split(":", 1)[1])
if query.from_user.id != expected_user:
await query.answer("This button is for the new member only.", show_alert=True)
return
chat_id = query.message.chat.id
# Restore the group's default permissions for this user.
chat = await context.bot.get_chat(chat_id)
permissions = chat.permissions or ChatPermissions(
can_send_messages=True,
can_send_audios=True,
can_send_documents=True,
can_send_photos=True,
can_send_videos=True,
can_send_video_notes=True,
can_send_voice_notes=True,
can_send_polls=True,
can_send_other_messages=True,
can_add_web_page_previews=True,
)
await context.bot.restrict_chat_member(chat_id, expected_user, permissions=permissions)
for job in context.job_queue.get_jobs_by_name(job_name(chat_id, expected_user)):
job.schedule_removal()
await query.answer("Thanks, you can write now.")
await query.message.delete()
async def kick_if_unverified(context: ContextTypes.DEFAULT_TYPE) -> None:
job = context.job
try:
# Ban + unban = remove the user, but allow them to join again later.
await context.bot.ban_chat_member(job.chat_id, job.user_id)
await context.bot.unban_chat_member(job.chat_id, job.user_id, only_if_banned=True)
await context.bot.delete_message(job.chat_id, job.data)
except TelegramError as exc:
log.warning("Could not remove %s: %s", job.user_id, exc)
def main() -> None:
app = Application.builder().token(os.environ["BOT_TOKEN"]).build()
app.add_handler(MessageHandler(filters.StatusUpdate.NEW_CHAT_MEMBERS, on_new_members))
app.add_handler(CallbackQueryHandler(on_button, pattern=r"^captcha:\d+$"))
app.run_polling(allowed_updates=Update.ALL_TYPES)
if __name__ == "__main__":
main()
What the important parts do
ChatPermissions.no_permissions()mutes the member completely (text, media, polls, stickers).run_once(..., chat_id=..., user_id=..., data=message_id, name=...)stores everything the timeout needs inside the job, so no global state is required. The job name lets the button handler find and cancel it.- The button handler compares
query.from_user.idwith the id in the callback data. Other members get a pop-up instead of verifying someone else. - On success the bot restores
chat.permissions, i.e. whatever you configured as the group’s default member permissions, instead of granting more than other members have. ban_chat_memberfollowed byunban_chat_member(..., only_if_banned=True)is the usual way to “kick” in the Bot API: the user is removed but may re-join.
5. Run it and test it
export BOT_TOKEN="123456789:AA...your-token..." # Windows PowerShell: $env:BOT_TOKEN="..."
python captcha_bot.py
Test it with a second Telegram account (or ask a friend): join the group, check that you cannot write, tap the button, and check that you can write again. Then join once more and wait two minutes without tapping – you should be removed. For a quick test, set CAPTCHA_SECONDS = 20.
The bot uses long polling, so it runs on any computer or small VPS with outgoing internet access; no domain, open port or HTTPS certificate is needed. It only works while the script runs – for 24/7 use, run it on a server with a process manager (systemd, Docker with restart: unless-stopped, or a hosting service).
How we tested this code: with python-telegram-bot 22.8 on Python 3.13 we ran ruff (no findings), imported it, built an Application with a job queue, and ran offline unit tests with a mocked Telegram API for all four paths: new member muted and job scheduled, wrong user presses the button, right user is unmuted with the group defaults, timeout removes the user. We did not run it in a live group for this guide.
6. Limits and troubleshooting
- Pending captchas are lost on restart. The job queue lives in memory. If the bot restarts while someone is muted, that person stays muted until an admin unmutes them. For production, store pending captchas in a file or database and re-schedule them on start.
- “Not enough rights to restrict/unrestrict chat member”: the bot is not an admin, lacks Ban users, or the user is an admin/owner (admins cannot be restricted).
- Nothing happens when someone joins: check that the script is running, that only one copy runs per token, and that the group does not use join requests (approve-to-join). With join requests, handle
ChatJoinRequestHandlerinstead. - Determined spammers can solve a button captcha by hand. Combine it with a link filter and a flood limit for better protection.
- The welcome text uses HTML mode;
mention_html()escapes the user’s name for you. If you add your own text with<or&, escape it withhtml.escape().
Want more than the captcha?
Our Telegram Group Bot Starter Kit ($19, source code) builds on the same approach and adds a configurable welcome message, link and banned-word filters, flood protection, a /price command, admin commands (/ban, /mute, /stats …), a Docker setup, a step-by-step README and 67 offline tests. You still host it yourself. The code in this guide is free to use as it is – you do not need the kit to run it.