Automating subscription access and invoicing
Someone pays, and in that moment they have the invoice and the entry link. On expiry the system politely lets them out.
- The situation
- You sell subscription content or a service. Access lives in a private group. And for every single customer it is you who sends the invite, you who writes the invoice, you who keeps the expiry date in your head.
- The approach
- The customer pays by card and immediately receives the invoice and a one-time entry link. The system notes the expiry, writes a new invoice at renewal, and on expiry removes the member.
- The result
- Payment, invoice, access, renewal, expiry — nobody touches any of it by hand. The operator spends his time on what people pay him for, not on the admin.
The system in numbers
| External systems connected | 3 — Stripe, Telegram, Számlázz.hu |
|---|---|
| Telegram channels | 3 — two paid, one free |
| Live launch | spring 2026 |
| Ongoing collaboration | since August 2025 |
- Project
- a sports-analysis information service
- Role
- Sole developer — architecture, integrations, operations
- Arrangement
- Monthly maintenance with ongoing development
- 01 Customer card payment
- 02 Stripe Checkout subscription, renewal
- 03 The system custom PHP, own tables
- 04 Telegram entry one-time link
- 05 Hungarian invoice Számlázz.hu
- 06 Notification email for the full lifecycle
event · webhook
Why subscriptions cannot be managed by hand
The client had a free Telegram channel and nothing to charge money for it with. Done by hand, every customer means sending an invite. Writing an invoice, noting down somewhere when it expires, and on the day of expiry, removing that person.
Up to a few dozen customers this works, at a few hundred it does not, and the errors surface exactly where they cost the most. Someone pays and gets no entry link. Or someone's subscription has expired and they stay inside. The first makes an angry customer and a refund, the second is simply money that does not come in.
What this means if your situation is similar
What makes a system like this hard is not the amount of code. It is that there is money at the end, and a door that either opens or does not. A badly loaded page gets reloaded by the visitor; a missing entry link cannot be reloaded — it has to be handled.
So most of the time goes not into writing the happy path but into the bad cases: what happens when the payment only succeeds on the third try, when the provider reports the same thing twice, when the hosting happens to block an outgoing call, or when someone cancels and changes their mind two weeks later.
The system went live in the spring of 2026 and has grown ever since. New packages, promotional campaigns, invoicing refinements. The collaboration has run since August 2025, on monthly maintenance.
If this sounds familiar — subscriptions, gated access, invoices written by hand or invites sent by hand — write a few sentences about how it works for you now. One exchange of emails usually shows whether it can be automated, and roughly how much work it is.
kristof@kristofkarner.comFrom here on come the implementation details: the background of the decisions, and the traps we ran into along the way. If you only wanted to know what can be achieved, everything is above — this part shows the reasoning.
| Payment event types handled | 11 |
|---|---|
| Custom code | 82 functions, 6,400+ lines of PHP |
| Own data layer | separate MySQL tables for state |
The decisions
Why Stripe, and not a local payment gateway
I built the payment on Stripe Subscriptions, not on SimplePay or a Revolut integration. This is what I wrote to the client in the quote, before touching anything:
“I will build the payment solution on Stripe rather than a SimplePay or Revolut integration. The advantage is that Stripe is made specifically for subscription systems and automated flows, so it can be wired to the invoicing and to the Telegram access.” Kristóf Karner's quote, August 2025 — translated from Hungarian
Recurring charges, cancellation, retries after a failed payment, expiry — all of that is included in Stripe's subscription engine. A gateway made for one-off payments would have been cheaper, but then all of that would have been ours to write — and every home-made piece is one more place where money can get lost. Discount campaigns run on Stripe Promotion Codes for the same reason, with usage limits and expiry dates, not on our code.
Why the payment provider speaks, and we do not ask
In this system every state change is started by a webhook arriving from Stripe, not by us polling. I did it this way because the hosting sometimes blocked outgoing Stripe calls. A solution that is cleaner in theory, one that calls outward, would have failed unpredictably in this environment.
The system handles eleven kinds of payment events, from the successful purchase through invoice finalisation and failed payment to the pausing and ending of a subscription. Where an outgoing call cannot be avoided, there is a fallback: a notification goes out so the operation can be done by hand.
$ grep -oE "'[a-z_]+\.[a-z_.]+'" functions.php | sort -u
'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'
How a Hungarian invoice gets issued automatically
The Hungarian fiscal invoices are issued by the Számlázz.hu Agent API, over an XML request, at the first purchase and at every renewal. This is needed because what Stripe issues is not a Hungarian invoice.
Hungarian tax law spells out exactly what may appear on an invoice. The wording and form of the line items were put together with the client's accountant, and they have not been touched since. I still do not touch an invoice field without checking first, because a wrong invoice line is hard to put right afterwards.
How access management works in Telegram
After a successful payment the system uses the Telegram Bot API to create an invite that works once and expires. The link cannot be passed on, and cannot be reused. On cancellation, or after a final payment failure, the same bot removes the member.
There are three channels: two paid, one free. The free one is not a by-product — it is where newcomers first walk in.
Why the system got its own data layer and an operator panel
The system creates and maintains its own MySQL tables for the state of subscriptions and issued invites. This could not be left to the WordPress core structures, because user metadata is not made for storing transactional state — it carries no reliable uniqueness guarantee.
Next to it went an operator panel. An error log, test payment, test invoicing, a promotion manager, and a logging layer for every operation. Where real money moves, two things count for a lot: seeing what happened, and being able to test without a single real customer noticing. The log stores identifiers masked, so debugging does not mean wading through personal data.
The parts that are not obvious
The next four things are not in the documentation. They show up in production, and each one is fairly easy to walk into.
The same payment event can arrive twice
Stripe resends a notification when it gets no timely answer. That is correct behaviour on the provider's part, but it means every operation may run twice. Invite-sending is therefore recorded in the database, so the same customer cannot get two entry links to the same channel. Without that, duplicate invites would have existed — ones that can be passed on.
In Telegram, removal means forever by default
In the Telegram Bot API, removal
(banChatMember)
is also a ban, and a banned member cannot rejoin. So right after the removal
the system
lifts
the ban. The expired subscriber leaves the channel, but can come back as a
paying customer any time.
$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
]);
- 01 Expiry or cancellation
- 02 banChatMember removes — and bans
- 03 unbanChatMember lifts the ban
- 04 Out, but free to return can buy again, nothing in the way
at once · without the second call it would stop here
A default that is easy to miss would have closed the door here on every returning customer. And nobody would have noticed for months — only the money that stops coming.
Signature checking has to come before logging too
The system verifies each incoming webhook with an HMAC signature before doing anything with its contents — before logging it, even. If the check ran after logging, anyone who knows the endpoint's address could write arbitrary data into the log. Beyond faked subscription activations, that is an attack surface in itself.
One typo takes the whole site down
The business logic lives in a single PHP file of several thousand lines, loaded by the live system on every request. A syntax error here does not produce an error message but a white screen across the whole site, payment flow included. 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 entry link. On another project the same thought grew into separate deploy scripts — I wrote about it there.
Questions about this project
Can access to a private Telegram group be tied to a subscription?
It can. In the Telegram Bot API a bot can create an invite link that works once and expires, and it can also remove a member. Hook that up to the payment provider's subscription events, and payment grants access by itself while cancellation takes it away by itself. This system has run that way since the spring of 2026.
Do you need separate Hungarian invoicing next to Stripe?
You do, because what Stripe issues is not a Hungarian invoice. A Hungarian customer must receive a Hungarian fiscal invoice, and there is no way around that. In this system the Számlázz.hu Agent API issues it, over XML, at the first purchase and at every renewal. The invoice line wording was put together with the client's accountant, and it has not been touched since.
What happens when someone cancels and then comes back later?
They can come back, if the system was written for it. In the Telegram Bot API a removal is also a ban by default, and a banned member cannot rejoin — so this system lifts the ban right after the removal. An expired subscriber leaves the channel but can buy again any time. Without that, the door would close on every returning customer.
What if the hosting blocks outgoing calls to the payment provider?
Then the direction has to be turned around. Instead of the website asking the payment provider, the provider notifies the website of every change. This is webhook-based operation. I designed this system that way because the hosting sometimes blocked outgoing Stripe calls. Where an outgoing call cannot be avoided, a notification goes out so the operation can also be done by hand.
How long does it take to build a system like this?
The development is a matter of weeks; the full run takes longer. For this client, several months passed between the quote and the live launch, because the package structure had to be worked out, the legal documents were prepared, the invoice lines were agreed with the accountant, and the client had to get ready too. The developer's work is only one piece of that.
Summary
Between August 2025 and spring 2026, a subscription system was built for the private Telegram channels of a sports-analysis service: Stripe Subscriptions handles payment, Hungarian fiscal invoices are issued through the Számlázz.hu Agent API over XML, and access is granted and revoked through one-time invites from the Telegram Bot API. Every state change is driven by Stripe webhooks, because the hosting sometimes blocked outgoing calls; the system handles eleven event types, verifies HMAC signatures before logging, and filters re-delivered events against invites recorded in the database. Removal is two calls: banChatMember followed immediately by unbanChatMember, so the door stays open for returning customers. An operator panel provides an error log, test payments and a promotion manager. The system has been live since spring 2026, on monthly maintenance.