Build Systeme.io
KINDAreplaces $17/mosaves $204/yrback to the verdict
A single operator funnel and course platform whose hard problems are a payment webhook that must be idempotent and replayable, access that has to be granted and revoked as a ledger rather than a flag, and a checkout that records the consent a refund argument will turn on.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- installfree
Why Runs the application, the worker and the replay command.
Get it nodejs.org, or your version manager. open ↗
Verify
node --version prints v22 or higher - accounta percentage of each sale plus a fixed fee; refunds do not return that fee
Why You are taking money from consumers, and a hosted checkout page keeps card data out of your application entirely.
Get it Create the account, complete its verification, and get the test and live keys plus the webhook signing secret.
Verify
A test payment completes and your endpoint receives a signed event you can verify - API keyusually free at small volume
Why Receipts and sequences go through it, and verifying the domain is the one setup step that cannot be skipped.
Get it Create an account, verify the domain by adding the records it asks for, and take an API key.
Verify
A receipt arrives and the provider reports it delivered - have readya few pounds a month
Why The database holds your contact list and your order history, so a platform that resets its disk on deploy is not an option.
Get it Any small virtual machine with a volume, plus a domain and a certificate.
Verify
A restart preserves the database and the webhook endpoint is reachable over your domain - decidefree
Why This wording is what a refund dispute turns on, and writing it once and storing it per order is the difference between a record and an argument.
Get it Write both sentences, check the current rules for the countries you sell into, and version the text.
Verify
An order row stores the wording and the version, and changing the site's terms does not alter it
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- contacts(id, email, name, source_page, created_at, confirmed_at, unsubscribed_at, tags_json) - orders(id, contact_id, session_id, product_id, amount_minor, tax_minor, currency, state, consent_text, consent_at) · consent_text is the exact wording the buyer was shown - events(id, processor_event_id, type, received_at, payload_json, processed_at, result) · processor_event_id is unique, and that uniqueness is the whole idempotency story - entitlements(id, contact_id, product_id, granted_by_event, granted_at, revoked_by_event, revoked_at) · a ledger, so access is a sum rather than a flag - progress(contact_id, lesson_id, completed_at) and lessons defined by files rather than rows - referrals(code, contact_id, first_seen_at) and commissions(id, order_id, code, amount_minor, state) · recorded, never paid out by this application - sends(id, contact_id, kind, subject, provider_message_id, sent_at, failed_reason) · one row per message, which is how you answer what somebody received - There is no boolean saying a person has access. Access is the sum of grant and revoke rows, so a refund followed by a repurchase leaves a history you can read.
Environment variables
These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DATABASE_PATH | required | ./data/app.db | Contacts, orders, events, entitlements and sends. This is the business. |
PROCESSOR_SECRET_KEYsecret | required | sk_live_...redacted | Server side only. Never in a page, never in a log. |
PROCESSOR_WEBHOOK_SECRETsecret | required | whsec_...redacted | Verifies every incoming event before the body is parsed. |
EMAIL_API_KEYsecret | required | re_...redacted | Receipts, sequences and broadcasts. Verify the sending domain first. |
ADMIN_PASSWORDsecret | required | a long random string, redacted | The single operator login. There are no other accounts. |
APP_URL | required | https://school.example | Used in checkout return links, magic links and unsubscribe links. |
CONSENT_TEXT_VERSION | required | 2026-09-15 | Stamped on every order alongside the wording itself. |
REFERRAL_COOKIE_DAYS | required | 60 | First-party attribution window for affiliate codes. |
The build, in order
Pages and opt-ins that are files
Funnel and lesson content lives in the repository, and an opt-in writes a contact you own.
Content in git is content you can diff, review and roll back.
Files
content/pages/src/content/load.tsIt is the only context you will have later.
Files
src/contacts/create.tstests/optin.test.tsThey answer different questions and conflating them loses both.
Files
src/contacts/state.ts
done when · tick each as it passeswatch out- Storing opt-ins without recording which page and which form they came from leaves you, six months later, with a list you cannot segment and cannot explain, which matters the first time somebody asks why they are receiving something. Record the source page and the timestamp on every contact, and keep the confirmation state separate from the subscription state.
A checkout that records what you promised
Buying records the price, the tax shown, and the consent wording the buyer agreed to.
A surprise at the card screen is a refund request in a week.
Files
src/checkout/session.tsA link to your terms is not a record of what was agreed.
Files
src/checkout/consent.tstests/consent.test.tsThey keep the cancellation right, and your flow should say so.
Files
src/checkout/session.ts
done when · tick each as it passeswatch out- Putting the consent and the acknowledgement into a link to your terms means you cannot show what the buyer actually agreed to, and a refund dispute then turns on a page you have since edited. Store the exact text shown, with its version and timestamp, on the order row itself, so the record survives every later change to your terms.
A webhook you can replay
Payment events are verified, stored raw, processed exactly once, and safe to replay.
An unverified body is a stranger's instruction.
Files
src/webhook/verify.tsThe database constraint is the idempotency mechanism.
Files
migrations/003_events.sqltests/idempotency.test.tsBeing able to rebuild state from events is what saves you later.
Files
src/webhook/replay.tsterminalnpm run replay -- --since=2026-09-01
done when · tick each as it passeswatch out- Treating each webhook delivery as a fresh instruction means a retry, which processors send routinely, grants access twice, sends two receipts and writes two commission rows, and you find out from the affiliate. Make the processor's event identifier unique in your table, decide idempotently from stored state, and test by delivering the same event three times.
Access as a ledger, and what a refund means
Granting, refunding and disputing all write rows, and the course reads the sum of them.
History is the thing you will need and cannot reconstruct.
Files
src/entitlements/ledger.tstests/ledger.test.tsOtherwise you pay a commission on a refunded sale.
Files
src/affiliates/commissions.tsThis screen is what you open when somebody is upset.
Files
src/app/admin/orders/[id]/page.tsx
done when · tick each as it passeswatch out- Flipping a boolean on the contact when a refund arrives loses the fact that they once had access, which matters when the same person buys again, when an affiliate asks why a commission vanished, and when you need to explain a dispute six months later. Write a revoke row naming the event, and let access be computed rather than stored.
Rent the sending, own the listproduct builder
Email goes out through a provider, unsubscribes take effect immediately, and everything exports.
The list you built an hour ago is already out of date.
Files
src/email/send.tstests/unsubscribe.test.tsIt is how you answer what somebody did or did not receive.
Files
src/email/log.tsOwning the list means being able to leave with it, and to remove somebody from it.
Files
src/export/all.tssrc/contacts/redact.tsterminalnpm run export -- --all
done when · tick each as it passeswatch out- Building the recipient list when a broadcast is queued and sending it an hour later mails everybody who unsubscribed in that hour, which is the complaint that costs you the sending account. Check the unsubscribe state again at the moment of each send, not only when the batch was built.
That is the whole plan for Systeme.io. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Drag and drop page and email editors.
- Affiliate payouts.
- Webinars and a blog.
- Working out your tax registrations for you.
- Visual page and email editors, plus the template library that makes launching a funnel a copy-paste job
- Managed deliverability: shared sending reputation, bounce suppression, spam complaint handling
- Affiliate program plumbing at the level people expect, including payouts and dashboards for your affiliates
- Webinars, blogs, and the odd extras that come free in the bundle
- One vendor to blame when checkout breaks on launch day
- A monthly reconciliation report comparing your orders against the processor's payouts line by line
- A second product and an upsell path, which is mostly checkout plumbing once the ledger exists
- A read-only export handed to your accountant on a schedule rather than at year end
Need the files? The project pack on the verdict page hands your agent the whole brief · more creator commerce.