Case study · Subscription system

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

Key facts about the subscription system
External systems connected3 — Stripe, Telegram, Számlázz.hu
Telegram channels3 — two paid, one free
Live launchspring 2026
Ongoing collaborationsince August 2025
Project
a sports-analysis information service
Role
Sole developer — architecture, integrations, operations
Arrangement
Monthly maintenance with ongoing development
How the subscription system fits together The customer pays on Stripe Checkout. Stripe sends an event to the system, which starts three things: it generates a one-time Telegram entry link, issues a Hungarian fiscal invoice through Számlázz.hu, and sends a notification email. Customer card payment Stripe Checkout subscription, renewal event · webhook The system custom PHP, own tables Telegram entry one-time link Hungarian invoice Számlázz.hu Notification email for the full lifecycle
After payment every branch starts on its own. On cancellation, or after a final payment failure, the same system removes the member from the channel.
  1. 01 Customer card payment
  2. 02 Stripe Checkout subscription, renewal
  3. 03 The system custom PHP, own tables
  4. 04 Telegram entry one-time link
  5. 05 Hungarian invoice Számlázz.hu
  6. 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.com
Technical details

From 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.

Size of the implementation
Payment event types handled11
Custom code82 functions, 6,400+ lines of PHP
Own data layerseparate 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.

Checked on the local copy — the handled event types, grepped from the code
$ 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.

The two calls in the system's codePHP
$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
]);
Removal that leaves the way back open When a subscription expires, the bot removes the member with banChatMember. By default this is also a ban, so right afterwards the system calls unbanChatMember, which lifts the ban. The member is out of the channel but can subscribe again any time. Expiry or cancellation banChatMember removes — and bans unbanChatMember lifts the ban Out, but free to return can buy again, nothing in the way at once · without the second call it would stop here
The two calls together mean “please leave” — the first one on its own would mean “leave and never come back”.
  1. 01 Expiry or cancellation
  2. 02 banChatMember removes — and bans
  3. 03 unbanChatMember lifts the ban
  4. 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.

Karner Kristóf

Independent developer in Budapest. I designed, built and operate this system alone — questions are welcome at the address in the contact section.

← All case studies