Selling access to a private Telegram channel or group sounds simple: take a payment, send a link. In practice the link is where most paid channels leak. A link gets forwarded, a member who stopped paying is still inside three months later, and a "free trial" turns into free access forever.
Here is how to build the access side properly with the Bot API: which links to create, how to gate joins, how to remove people when their time runs out, and the details that are easy to miss.
Option 1: let Telegram run the subscription (Stars)
If you charge in Telegram Stars and a monthly price works for you, Telegram can do the whole job. createChatSubscriptionInviteLink makes a link that charges the user when they join and again every period:
await call('createChatSubscriptionInviteLink', { chat_id: CHANNEL_ID, name: 'Monthly', subscription_period: 2592000, // must be exactly 30 days today subscription_price: 250, // Stars, 1-10000 }); Telegram handles renewals and removes people who stop paying. The limits: channels only, the period is fixed at 30 days, and the price is in Stars. If you need weekly or yearly plans, crypto or card payments, free trials or promo codes, you have to manage access yourself. The rest of this post is about that case.
Option 2: your bot controls access
Never hand out a reusable link
The primary invite link of a private channel works for anyone who has it. Do not send it to buyers. Create one link per purchase instead.
There are two good patterns.
A one-person, short-lived link:
const link = await call('createChatInviteLink', { chat_id: CHANNEL_ID, name: `order ${orderId}`.slice(0, 32), member_limit: 1, // one join, then it is used up expire_date: Math.floor(Date.now() / 1000) + 3600, // and it dies in an hour anyway }); A join-request link (better):
const link = await call('createChatInviteLink', { chat_id: CHANNEL_ID, name: `order ${orderId}`.slice(0, 32), creates_join_request: true, // member_limit is not allowed together with this }); With creates_join_request, clicking the link does not let anyone in. It sends your bot a chat_join_request update, and you decide. That closes the forwarding hole: even if the buyer posts the link publicly, you only approve the Telegram account that actually paid.
async function onJoinRequest(req) { const userId = req.from.id; const access = await db.findActiveAccess(req.chat.id, userId); if (access) { await call('approveChatJoinRequest', { chat_id: req.chat.id, user_id: userId }); } else { await call('declineChatJoinRequest', { chat_id: req.chat.id, user_id: userId }); } } The bot needs the can_invite_users admin right for both calls.
Revoke links you no longer need
When a purchase is refunded, or the buyer has joined, call revokeChatInviteLink on that link. Keep the link in your order record so you can find it later. A pile of old, still-valid links is a pile of free entrances.
Removing members when time runs out
Store a paid_until timestamp for each member and run a scheduler that checks it. Run that scheduler on one process only: if you run several workers, each one will try to remove the same person.
To remove someone without banning them for good, there is a neat detail in the docs: unbanChatMember called on a current member removes them, and leaves them free to come back later through a new link. So a single call does it:
// removes the member if they are in the chat; they can rejoin after paying again await call('unbanChatMember', { chat_id: CHANNEL_ID, user_id: userId }); If you want them out and unable to return until you say so, use banChatMember instead. Watch its until_date: a ban shorter than 30 seconds or longer than 366 days counts as permanent.
Two more rules that save support tickets:
- Never remove admins or the owner. Check
getChatMemberfirst and skip anyone whose status isadministratororcreator. - Send reminders before the end, for example 3 days and 1 day before, with a renew button. Most "you kicked me" complaints come from people who simply forgot.
Know who is in the chat
To see joins and leaves you need chat_member updates. Two catches:
- The bot must be an admin in the chat.
- You must list
chat_memberinallowed_updatesexplicitly. The default (empty) list gives you every update type exceptchat_member,message_reactionandmessage_reaction_count.
await call('setWebhook', { url: WEBHOOK_URL, secret_token: WEBHOOK_SECRET, allowed_updates: ['message', 'callback_query', 'chat_join_request', 'chat_member', 'pre_checkout_query'], }); Once you set an explicit list, any update type you leave out stops arriving, so keep the list in one place in your code.
Free trials and promo codes
- One trial per Telegram user id, stored permanently. Trials keyed on anything else (a username, a chat) get repeated.
- Trials use the same join-request flow with a short
paid_until. Do not create a special link that skips the check. - Promo codes should have a use limit and an end date, and the discount should be recorded on the order so refunds stay correct.
A checklist
- Per-purchase links only, never the primary link.
- Prefer
creates_join_requestand approve only the account that paid. - Revoke links after use or refund.
-
paid_untilper member, checked by one scheduler. -
unbanChatMemberto remove without a permanent ban; never touch admins. - Reminders before expiry.
-
chat_memberexplicitly inallowed_updates. - One trial per user id.
I work on TeleCrow, a no-code builder for Telegram bots, where paid channels and groups with automatic expiry, reminders, free trials and promo codes are one of the main bot types. If you are building this yourself, I hope the list above saves you the leaks we have seen.
Related: Running hundreds of Telegram bots on one Node.js server: 8 lessons from production.
Disclosure: I work on TeleCrow.
