Since December 2025 a sports-analysis service has been selling access to its private Telegram channels through a system I built and maintain. The buyer pays on the website, receives a Hungarian invoice and a one-time invite at once, and when the subscription ends, the bot removes them from the channel. Nobody touches any of it by hand.
The stack is plain: WordPress with a custom PHP layer, Stripe Subscriptions for payment, the Telegram Bot API for access, and the Számlázz.hu Agent API for the Hungarian invoices. Most of the work went into the bad cases rather than the happy path, and these are the parts that took the most thought.
Let the payment provider speak
Every state change in the system starts with a webhook from Stripe; the system never polls. I chose this because the hosting sometimes blocked outgoing calls to Stripe, and a design that depended on calling out would have failed unpredictably in that environment.
The system handles eleven event types:
charge.dispute.created
checkout.session.completed
customer.subscription.created
customer.subscription.deleted
customer.subscription.paused
customer.subscription.resumed
customer.subscription.updated
invoice.created
invoice.finalized
invoice.payment_failed
invoice.payment_succeeded
Where an outgoing call cannot be avoided, there is a fallback: a notification goes out, so the step can be done by hand.
Verify the signature before logging
Each incoming webhook is checked against its HMAC signature before anything else happens to it, logging included. If the check ran after the log write, anyone who knew the endpoint's address could write arbitrary data into the log, quite apart from faking a subscription activation.
The same event can arrive twice
Stripe resends a webhook when it does not get a timely answer. That is correct behaviour on Stripe's part, and it means every handler may run twice. Sending an invite is therefore recorded in the database, and a second delivery of the same event finds the record and stops there. Without it, a customer could end up with two invites to the same channel, and the spare one could be passed on.
Removing a member is a ban
In the Telegram Bot API, banChatMember is the call that removes someone, and by default it also bans them: a banned user cannot rejoin through an invite link. For a paid channel that would lock the door on every returning customer, and the only sign of it would be the renewals that never come.
In this system, removal is two calls:
$ban = wp_remote_post('https://api.telegram.org/bot'.$token.'/banChatMember', [
'body' => ['chat_id'=>$chat_id, 'user_id'=>$user_id, 'until_date'=>time()+60],
'timeout' => 15
]);
// …error handling…
$unban = wp_remote_post('https://api.telegram.org/bot'.$token.'/unbanChatMember', [
'body' => ['chat_id'=>$chat_id, 'user_id'=>$user_id],
'timeout' => 15
]);
The until_date one minute ahead matters too. According to the Bot API documentation, in channels and supergroups a ban lifts itself at that moment, unless it is shorter than 30 seconds or longer than 366 days — those count as permanent. So even if the unban call failed, the member would be locked out for a minute, not for good.
The documentation also offers a shorter route: unbanChatMember called on a current member removes them as well, because by default it guarantees that the user is not a member after the call, while leaving them free to rejoin.
The invites themselves are created per payment, work once and expire, so they cannot be shared.
A Stripe receipt is not a Hungarian invoice
Hungarian buyers must receive a Hungarian fiscal invoice, and what Stripe sends is not one. The system issues it through the Számlázz.hu Agent API, with an XML request, at the first purchase and at every renewal. The wording of the invoice lines was agreed with the client's accountant, and I have not changed it since, because a wrong invoice line is hard to put right afterwards.
If you sell to Hungarian buyers through Stripe, I compared the ways to get from a Stripe payment to a Hungarian invoice in a separate piece.
State lives in its own tables
Subscription state and issued invites live in the system's own MySQL tables, not in WordPress user meta, which carries no uniqueness guarantee — and transactional state needs one. Next to the tables sits a small operator panel: an error log, test payments, test invoicing, a promotion manager (campaigns run on Stripe Promotion Codes, with usage limits and expiry dates), and a log of every operation, with identifiers masked so that debugging does not mean reading personal data.
One typo, white screen
The business logic sits in a single PHP file of several thousand lines, which the live site loads on every request. A syntax error there takes down the whole site, checkout included, without an error message. So before every change a snapshot of the live file is taken, and after every deployment a complete purchase runs through: real payment, real invoice, real invite.
The full write-up, with the reasoning behind each decision and a diagram of the flow, is on my site. The access lifecycle on its own — single-use invites, pairing a payment with a Telegram account, expiry with a grace period, and removal that lets the member return — is on GitHub as telegram-subscription-access.
Top comments (0)