The Sunday night problem: automating Discord access after a Stripe payment

Some problems announce themselves with a spreadsheet. This one announced itself with a Sunday evening.
Two founders run an online course. People buy it, and every buyer gets a VIP role in a private Discord community, which is where the real value of the product lives: the questions, the feedback, the other members who are a month ahead of you. The payment side worked beautifully. The access side was two humans, by hand, one buyer at a time.
That is perfectly fine at ten customers. At a few hundred it means one of them is opening Stripe on a Sunday evening because somebody paid two hours ago and has been waiting, very politely, in the DMs. Find the payment. Find their Discord handle. Assign the role. Hope they spelled their username the same way they did at checkout.
They wanted the whole thing to disappear. Good goal. Let's make it disappear.
The shape of it
Two n8n workflows, and no server to babysit.
The first one listens to Stripe. A payment succeeds, and the workflow records the customer and the payment in Airtable, generates a single-use token, and emails the buyer a personalised invitation link.
The second one catches the Discord OAuth callback. The buyer clicks, authorises the app, and the workflow validates the token and calls the Discord API to add them to the server with the VIP role already on.
That is the entire architecture. It fits in two paragraphs, which is usually a good sign.
Getting there took three detours, and the detours are the interesting part.
Detour one: Stripe has several ways of saying "they paid"
The obvious event to listen for is checkout.session.completed. Someone completed a checkout, that's your customer, done.
Except this course is also sold in three instalments. checkout.session.completed fires once, on the first payment. Months two and three go by in complete silence. Anyone on a payment plan would exist in the system for a month and then quietly stop existing.
So: invoice.payment_succeeded, which does cover the instalments. It also covers the free trials that the course platform creates automatically at 0 EUR. Listening to that event would hand a VIP role to everyone who signed up for a trial and never paid a cent. Not ideal for a paid community.
The one that actually describes what we care about is charge.succeeded. It fires when money moves. Trials do not move money, so they never fire it, and they filter themselves out with no exception list to maintain and no clever conditions to remember six months later.
Small decision. It is also the difference between an integration that works and one that quietly leaks free memberships.
Detour two: the shortcut that would have aged badly
Once payments were flowing in, we needed to tell a one-off purchase from an instalment plan, because they are treated differently downstream.
The first version did it by price. Above a certain amount meant one-off, below meant instalment. It worked immediately, which is exactly what makes that kind of shortcut dangerous: nothing tells you it is fragile while it is working.
But the day the founders run a promotion, or raise their prices, or add a cheaper tier, every new record gets tagged wrong. And nothing breaks. No error, no alert, no red anything. Just a slowly growing pile of bad data that somebody discovers three months later while trying to understand their own numbers.
The Stripe payload already answers the question honestly. A charge attached to an invoice belongs to a subscription. A charge with no invoice is a one-off. The test does not care what anything costs, so it survives every future price change.
The rule I keep coming back to: if your logic breaks when a number changes, it is a workaround wearing the costume of a design. Sometimes shipping the workaround is the right call. It should just be written down as one, and replaced before it is forgotten.
Detour three: the one that made me sit up
The first version worked. Customers paid, got their email, clicked, landed in Discord with their VIP role. Everyone was happy, including me.
Then I looked at the invitation link one more time.
To recognise the buyer when Discord sends them back, the workflow was passing their email address in the OAuth state parameter. Discord returns state untouched, the workflow reads the email, looks the customer up, grants the role. Clean. Readable. Completely open.
state is a value the user controls. It sits right there in the URL. Any buyer, or anyone they forwarded the email to, could read their own address in that link, swap in a different one, and be handed VIP access to a paid community without paying for it. No hacking involved. Curiosity and a text editor.
The fix was to stop putting meaning in that parameter at all. At payment, the workflow now generates a random token, stores it on our side against that specific order, and puts only the token in the link. It says nothing about who you are. It only proves that this exact link was issued for one paid order, that it has not been used yet, and that it is less than 48 hours old.
Cost of the fix: one extra column in the database, one validation step in the workflow. Roughly an afternoon.
It generalises far beyond Discord. Any value that travels out through the user's browser and comes back to you is a value the user can edit. If your access control trusts it, you do not have access control, you have a suggestion.
Where it stands
Both workflows run in production. Somebody buys the course at two in the morning, and thirty seconds later they are in the community with the right role, no human involved, no Sunday evening spent in the Stripe dashboard.
The founders get to go back to teaching, which is the part they are actually good at and the part their customers are paying for.
That is usually the real return on this kind of work. Not the hours saved on a spreadsheet, but the attention given back to the two or three things only the founders can do.
If this sounds familiar
Payment providers, community platforms and internal tools rarely talk to each other out of the box. The gap between them tends to get filled by a person doing the same five clicks, several times a day, forever.
Closing that gap is what I do. If you have one, tell me about it and I will tell you honestly whether it is worth automating.


