Vibecode Systeme.io
track this build4 phases, 12 steps, beginner friendly0%None of the individual pieces here are hard: a landing page, an opt-in form, a contacts table, a Stripe checkout, a gated video page, a ref-code cookie. An agent can wire all of that into one self-hosted app in a weekend and you will own the list outright. What you cannot one-shot is the boring operational layer: domain authentication, bounce and complaint handling, unsubscribe compliance, and the fact that sending 20,000 emails from your own setup means you now care about deliverability as a personal hobby. You also lose the drag-and-drop editor, which sounds cosmetic until you want to change a headline at 11pm without opening an editor. Verdict lands at kinda because the value survives the rebuild but the maintenance does not disappear.
You are building a lean indie version of Systeme.io. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Systeme.io · indie build 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. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | One web application, single operator, no multi-tenancy, no accounts beyond a magic link for buyers | you are building a shop for one person and every tenancy feature costs you a week | | Content | Pages, lessons and sequences as files in the repository | changing a headline should be a commit, not a database migration | | Payments | A hosted checkout session plus a verified webhook, never card details of your own | card handling is a compliance project and the hosted page exists to keep you out of it | | Access | Entitlements as grant and revoke rows, never a boolean on the contact | refunds, disputes and repurchases all need a history rather than a flag | | Email | A transactional provider for both receipts and broadcasts | the list is the asset worth owning; the sending reputation is not | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the application, the worker and the replay command. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **A payment processor account with hosted checkout and webhooks** · a 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 - [ ] **A transactional email provider with a verified sending domain** · usually 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 - [ ] **A host with a persistent volume and your own domain** · a 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 - [ ] **The exact wording of the consent and acknowledgement at checkout** · free - 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 ## Quick start ```sh npm run replay -- --since=2026-09-01 ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - 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 If one of those is essential to you, that is the reason to keep paying for Systeme.io, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Systeme.io The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a funnel, a course and a checkout you run yourself, with the contact list in your own database to replace Systeme.io. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two parts of this build involve other people's money and other people's inboxes, and both have rules you do not get to design around. Selling digital access to a consumer comes with a cancellation right. The United Kingdom regulations implementing the European consumer rights directive say a consumer buying digital content not on a physical medium loses the fourteen day right to cancel only if supply began during that period after the consumer gave express consent and acknowledged that the right would be lost. If your checkout did not capture both, the right survives and the argument afterwards is one you lose. So record the consent and the acknowledgement with the order, in the exact words the buyer was shown, and have the refund flow read them. Check the current rules for every country you sell into before writing the copy. Second, keep the list and rent the sending. Owning the contact database is the thing worth rebuilding here. Operating outbound mail yourself is a standing commitment to other people's inbox rules rather than a feature you finish, so send through a provider, and make sure every message carries an unsubscribe that takes effect the moment it is clicked. ### Stack (fixed, do not substitute) - Runtime: One web application, single operator, no multi-tenancy, no accounts beyond a magic link for buyers · you are building a shop for one person and every tenancy feature costs you a week - Content: Pages, lessons and sequences as files in the repository · changing a headline should be a commit, not a database migration - Payments: A hosted checkout session plus a verified webhook, never card details of your own · card handling is a compliance project and the hosted page exists to keep you out of it - Access: Entitlements as grant and revoke rows, never a boolean on the contact · refunds, disputes and repurchases all need a history rather than a flag - Email: A transactional provider for both receipts and broadcasts · the list is the asset worth owning; the sending reputation is not ### Data model (create this before Phase 1) - 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. ### Phase 1 · Pages and opt-ins that are files Build: Pages as files with front matter for slug, title and an optional product. Render server side with one plain layout. An opt-in form posts to your own endpoint, stores the contact with the page it came from, and optionally sends a confirmation link before the contact counts as confirmed. Seed one demo page and one demo contact so the first run shows something working. Trap: 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. Done when: A new page is one file and appears without a code change, tested. An opt-in stores the contact with its source page and creation time, and a duplicate address updates rather than creating a second row, tested. Confirmation state and unsubscribe state are separate columns and neither overwrites the other, asserted in the suite. The first run after seeding shows a working funnel page and one demo contact. Do not build yet: No checkout, entitlements, course gating or email sending yet. ### Phase 2 · A checkout that records what you promised Build: Create a hosted checkout session for a product, with the price and any tax shown before payment. On the page, require the express consent to immediate supply and the acknowledgement that the cancellation right is lost, as a separate tick with its own wording. Store that wording verbatim with the order, along with the time. Send a receipt naming what was bought and how to ask for help. Trap: 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. Done when: An order stores the consent wording verbatim with a timestamp, and editing the site's terms afterwards does not change any stored order, tested. The checkout shows the price and tax before payment and the stored amounts match what was displayed. A receipt is sent naming the product and the support route, recorded in the sends table. A buyer who declines the acknowledgement can still buy, and their order is marked as retaining the cancellation right. Do not build yet: No webhook handling, entitlements, course gating or broadcasts yet. ### Phase 3 · A webhook you can replay Build: Verify the signature before reading the body. Store the raw event keyed on the processor's event identifier with a unique constraint, then process. Processing is a function of the stored event, so replaying it changes nothing. Handle events arriving out of order and events for objects you have never seen. Provide a command to replay a range of events against the current code. Trap: 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. Done when: Delivering the same event three times produces exactly one grant, one receipt and one commission row, tested. An event with an invalid signature is rejected before the body is parsed, tested. An event referring to an unknown order is stored and retried rather than discarded, tested. Replaying a range of stored events against an empty database reproduces the same entitlement ledger, tested. Do not build yet: No refund handling, course gating or broadcasts yet. ### Phase 4 · Access as a ledger, and what a refund means Build: Grant an entitlement on payment, revoke it on refund or dispute, both as rows naming the event that caused them. The course area computes access from the ledger on every request. A refund also marks the commission reversed. Show the operator a per-order view with the consent wording, the events and the current access state on one screen. Trap: 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. Done when: Access is computed from grant and revoke rows on every request, with no boolean anywhere, verified by review. A refund revokes access and reverses the commission in one transaction, tested. A buyer who is refunded and buys again has two grants and one revoke in order, and access is correct at every point, tested. The per-order screen shows the consent wording, every related event and the current access state. Do not build yet: No broadcasts, exports or the operator's exit path yet. ### Phase 5 · Rent the sending, own the list Build: Send receipts, sequences and broadcasts through a provider, recording one row per message with the provider's identifier. Honour an unsubscribe at send time as well as at click time, so a queued sequence stops immediately. Export contacts, orders, entitlements, commissions and send history as CSV and JSON. Provide a per-contact delete that removes the personal data while keeping the financial record you are required to keep. Trap: 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. Done when: A contact who unsubscribes after a broadcast is queued receives nothing from it, tested by unsubscribing mid-run. Every message sent has a row with the provider's identifier and its outcome, checked after a real send. Export produces contacts, orders, entitlements, commissions and sends, and reimports into an empty database, tested. A per-contact delete removes personal fields while leaving the order and its amounts intact, with the redaction recorded. Do not build yet: Nothing after this. ### Out of scope (and why) - Drag and drop page and email editors, which are most of the paid product's surface and are a month of work for a headline you could change in a text file. - Affiliate payouts, which means moving money to other people and brings identity checks, tax forms and a different regulatory conversation. - Webinars and a blog, which are separate products bundled to make the price look better and share nothing with this build. - Working out your own tax registrations, since a calculation service computes a number but does not make you registered anywhere; check the thresholds where your buyers are before your first sale. ### README must contain - That the checkout records the express consent and the acknowledgement of losing the cancellation right, verbatim, on the order, and why a link to your terms is not enough - That the payment webhook is idempotent on the processor's event identifier and that stored events can be replayed to rebuild the entitlement ledger - That access is a ledger of grants and revokes rather than a flag, and what a refund and a dispute each write - That sending is rented rather than operated, and that unsubscribe state is checked again at the moment of each send - What a per-contact delete removes, what it keeps for financial records, and how to export everything ===== AGENTS.md ===== # Agent instructions · Systeme.io indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: One web application, single operator, no multi-tenancy, no accounts beyond a magic link for buyers, Pages, lessons and sequences as files in the repository, A hosted checkout session plus a verified webhook, never card details of your own, Entitlements as grant and revoke rows, never a boolean on the contact, A transactional provider for both receipts and broadcasts. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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. - 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. - 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. - 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. ===== BUILD_PLAN.md ===== # Build plan · Systeme.io 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Pages and opt-ins that are files Funnel and lesson content lives in the repository, and an opt-in writes a contact you own. ### Steps 1. Keep pages and lessons as files Content in git is content you can diff, review and roll back. Files: `content/pages/`, `src/content/load.ts` 2. Record the source page on every contact It is the only context you will have later. Files: `src/contacts/create.ts`, `tests/optin.test.ts` 3. Keep confirmation and subscription separate They answer different questions and conflating them loses both. Files: `src/contacts/state.ts` ### Done when - [ ] A new page is one file with no code change - [ ] Opt-ins record source page and time - [ ] Duplicate addresses update rather than duplicate - [ ] Confirmation and unsubscribe are separate states ### Watch 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. ## Phase 2 · A checkout that records what you promised Buying records the price, the tax shown, and the consent wording the buyer agreed to. ### Steps 1. Show price and tax before the payment, not after A surprise at the card screen is a refund request in a week. Files: `src/checkout/session.ts` 2. Store the consent wording verbatim on the order A link to your terms is not a record of what was agreed. Files: `src/checkout/consent.ts`, `tests/consent.test.ts` 3. Handle the buyer who declines They keep the cancellation right, and your flow should say so. Files: `src/checkout/session.ts` ### Done when - [ ] Consent wording is stored verbatim and immutable - [ ] Displayed price and tax match the stored amounts - [ ] A receipt is sent and recorded - [ ] Declining the acknowledgement is a supported path ### Watch 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. ## Phase 3 · A webhook you can replay Payment events are verified, stored raw, processed exactly once, and safe to replay. ### Steps 1. Verify the signature before parsing anything An unverified body is a stranger's instruction. Files: `src/webhook/verify.ts` 2. Make the processor event identifier unique The database constraint is the idempotency mechanism. Files: `migrations/003_events.sql`, `tests/idempotency.test.ts` 3. Provide a replay command and use it in tests Being able to rebuild state from events is what saves you later. Files: `src/webhook/replay.ts` ```sh npm run replay -- --since=2026-09-01 ``` ### Done when - [ ] The same event three times produces one of everything - [ ] Invalid signatures are rejected before parsing - [ ] Unknown-object events are stored and retried - [ ] Replay reproduces the same ledger ### Watch 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. ## Phase 4 · Access as a ledger, and what a refund means Granting, refunding and disputing all write rows, and the course reads the sum of them. ### Steps 1. Write grant and revoke rows, never a flag History is the thing you will need and cannot reconstruct. Files: `src/entitlements/ledger.ts`, `tests/ledger.test.ts` 2. Reverse the commission in the same transaction Otherwise you pay a commission on a refunded sale. Files: `src/affiliates/commissions.ts` 3. Put consent, events and access on one screen This screen is what you open when somebody is upset. Files: `src/app/admin/orders/[id]/page.tsx` ### Done when - [ ] Access is computed from the ledger, never a flag - [ ] Refunds revoke access and reverse the commission together - [ ] Refund then repurchase leaves correct access throughout - [ ] The order screen shows consent, events and access ### Watch 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. ## Not in this build - Drag and drop page and email editors. - Affiliate payouts. - Webinars and a blog. - Working out your tax registrations for you. ## After v1, if you want it - 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 ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Contacts, orders, events, entitlements and sends. This is the business. DATABASE_PATH=./data/app.db # Required · secret. Server side only. Never in a page, never in a log. PROCESSOR_SECRET_KEY=sk_live_...redacted # Required · secret. Verifies every incoming event before the body is parsed. PROCESSOR_WEBHOOK_SECRET=whsec_...redacted # Required · secret. Receipts, sequences and broadcasts. Verify the sending domain first. EMAIL_API_KEY=re_...redacted # Required · secret. The single operator login. There are no other accounts. ADMIN_PASSWORD=a long random string, redacted # Required. Used in checkout return links, magic links and unsubscribe links. APP_URL=https://school.example # Required. Stamped on every order alongside the wording itself. CONSENT_TEXT_VERSION=2026-09-15 # Required. First-party attribution window for affiliate codes. REFERRAL_COOKIE_DAYS=60
You are building a lean indie version of Systeme.io. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Systeme.io · indie build 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. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | One web application, single operator, no multi-tenancy, no accounts beyond a magic link for buyers | you are building a shop for one person and every tenancy feature costs you a week | | Content | Pages, lessons and sequences as files in the repository | changing a headline should be a commit, not a database migration | | Payments | A hosted checkout session plus a verified webhook, never card details of your own | card handling is a compliance project and the hosted page exists to keep you out of it | | Access | Entitlements as grant and revoke rows, never a boolean on the contact | refunds, disputes and repurchases all need a history rather than a flag | | Email | A transactional provider for both receipts and broadcasts | the list is the asset worth owning; the sending reputation is not | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the application, the worker and the replay command. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **A payment processor account with hosted checkout and webhooks** · a 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 - [ ] **A transactional email provider with a verified sending domain** · usually 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 - [ ] **A host with a persistent volume and your own domain** · a 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 - [ ] **The exact wording of the consent and acknowledgement at checkout** · free - 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 ## Quick start ```sh npm run replay -- --since=2026-09-01 ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - 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 If one of those is essential to you, that is the reason to keep paying for Systeme.io, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Systeme.io The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a funnel, a course and a checkout you run yourself, with the contact list in your own database to replace Systeme.io. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two parts of this build involve other people's money and other people's inboxes, and both have rules you do not get to design around. Selling digital access to a consumer comes with a cancellation right. The United Kingdom regulations implementing the European consumer rights directive say a consumer buying digital content not on a physical medium loses the fourteen day right to cancel only if supply began during that period after the consumer gave express consent and acknowledged that the right would be lost. If your checkout did not capture both, the right survives and the argument afterwards is one you lose. So record the consent and the acknowledgement with the order, in the exact words the buyer was shown, and have the refund flow read them. Check the current rules for every country you sell into before writing the copy. Second, keep the list and rent the sending. Owning the contact database is the thing worth rebuilding here. Operating outbound mail yourself is a standing commitment to other people's inbox rules rather than a feature you finish, so send through a provider, and make sure every message carries an unsubscribe that takes effect the moment it is clicked. ### Stack (fixed, do not substitute) - Runtime: One web application, single operator, no multi-tenancy, no accounts beyond a magic link for buyers · you are building a shop for one person and every tenancy feature costs you a week - Content: Pages, lessons and sequences as files in the repository · changing a headline should be a commit, not a database migration - Payments: A hosted checkout session plus a verified webhook, never card details of your own · card handling is a compliance project and the hosted page exists to keep you out of it - Access: Entitlements as grant and revoke rows, never a boolean on the contact · refunds, disputes and repurchases all need a history rather than a flag - Email: A transactional provider for both receipts and broadcasts · the list is the asset worth owning; the sending reputation is not ### Data model (create this before Phase 1) - 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. ### Phase 1 · Pages and opt-ins that are files Build: Pages as files with front matter for slug, title and an optional product. Render server side with one plain layout. An opt-in form posts to your own endpoint, stores the contact with the page it came from, and optionally sends a confirmation link before the contact counts as confirmed. Seed one demo page and one demo contact so the first run shows something working. Trap: 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. Done when: A new page is one file and appears without a code change, tested. An opt-in stores the contact with its source page and creation time, and a duplicate address updates rather than creating a second row, tested. Confirmation state and unsubscribe state are separate columns and neither overwrites the other, asserted in the suite. The first run after seeding shows a working funnel page and one demo contact. Do not build yet: No checkout, entitlements, course gating or email sending yet. ### Phase 2 · A checkout that records what you promised Build: Create a hosted checkout session for a product, with the price and any tax shown before payment. On the page, require the express consent to immediate supply and the acknowledgement that the cancellation right is lost, as a separate tick with its own wording. Store that wording verbatim with the order, along with the time. Send a receipt naming what was bought and how to ask for help. Trap: 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. Done when: An order stores the consent wording verbatim with a timestamp, and editing the site's terms afterwards does not change any stored order, tested. The checkout shows the price and tax before payment and the stored amounts match what was displayed. A receipt is sent naming the product and the support route, recorded in the sends table. A buyer who declines the acknowledgement can still buy, and their order is marked as retaining the cancellation right. Do not build yet: No webhook handling, entitlements, course gating or broadcasts yet. ### Phase 3 · A webhook you can replay Build: Verify the signature before reading the body. Store the raw event keyed on the processor's event identifier with a unique constraint, then process. Processing is a function of the stored event, so replaying it changes nothing. Handle events arriving out of order and events for objects you have never seen. Provide a command to replay a range of events against the current code. Trap: 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. Done when: Delivering the same event three times produces exactly one grant, one receipt and one commission row, tested. An event with an invalid signature is rejected before the body is parsed, tested. An event referring to an unknown order is stored and retried rather than discarded, tested. Replaying a range of stored events against an empty database reproduces the same entitlement ledger, tested. Do not build yet: No refund handling, course gating or broadcasts yet. ### Phase 4 · Access as a ledger, and what a refund means Build: Grant an entitlement on payment, revoke it on refund or dispute, both as rows naming the event that caused them. The course area computes access from the ledger on every request. A refund also marks the commission reversed. Show the operator a per-order view with the consent wording, the events and the current access state on one screen. Trap: 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. Done when: Access is computed from grant and revoke rows on every request, with no boolean anywhere, verified by review. A refund revokes access and reverses the commission in one transaction, tested. A buyer who is refunded and buys again has two grants and one revoke in order, and access is correct at every point, tested. The per-order screen shows the consent wording, every related event and the current access state. Do not build yet: No broadcasts, exports or the operator's exit path yet. ### Phase 5 · Rent the sending, own the list Build: Send receipts, sequences and broadcasts through a provider, recording one row per message with the provider's identifier. Honour an unsubscribe at send time as well as at click time, so a queued sequence stops immediately. Export contacts, orders, entitlements, commissions and send history as CSV and JSON. Provide a per-contact delete that removes the personal data while keeping the financial record you are required to keep. Trap: 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. Done when: A contact who unsubscribes after a broadcast is queued receives nothing from it, tested by unsubscribing mid-run. Every message sent has a row with the provider's identifier and its outcome, checked after a real send. Export produces contacts, orders, entitlements, commissions and sends, and reimports into an empty database, tested. A per-contact delete removes personal fields while leaving the order and its amounts intact, with the redaction recorded. Do not build yet: Nothing after this. ### Out of scope (and why) - Drag and drop page and email editors, which are most of the paid product's surface and are a month of work for a headline you could change in a text file. - Affiliate payouts, which means moving money to other people and brings identity checks, tax forms and a different regulatory conversation. - Webinars and a blog, which are separate products bundled to make the price look better and share nothing with this build. - Working out your own tax registrations, since a calculation service computes a number but does not make you registered anywhere; check the thresholds where your buyers are before your first sale. ### README must contain - That the checkout records the express consent and the acknowledgement of losing the cancellation right, verbatim, on the order, and why a link to your terms is not enough - That the payment webhook is idempotent on the processor's event identifier and that stored events can be replayed to rebuild the entitlement ledger - That access is a ledger of grants and revokes rather than a flag, and what a refund and a dispute each write - That sending is rented rather than operated, and that unsubscribe state is checked again at the moment of each send - What a per-contact delete removes, what it keeps for financial records, and how to export everything ===== AGENTS.md ===== # Agent instructions · Systeme.io indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: One web application, single operator, no multi-tenancy, no accounts beyond a magic link for buyers, Pages, lessons and sequences as files in the repository, A hosted checkout session plus a verified webhook, never card details of your own, Entitlements as grant and revoke rows, never a boolean on the contact, A transactional provider for both receipts and broadcasts. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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. - 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. - 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. - 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. ===== BUILD_PLAN.md ===== # Build plan · Systeme.io 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Pages and opt-ins that are files Funnel and lesson content lives in the repository, and an opt-in writes a contact you own. ### Steps 1. Keep pages and lessons as files Content in git is content you can diff, review and roll back. Files: `content/pages/`, `src/content/load.ts` 2. Record the source page on every contact It is the only context you will have later. Files: `src/contacts/create.ts`, `tests/optin.test.ts` 3. Keep confirmation and subscription separate They answer different questions and conflating them loses both. Files: `src/contacts/state.ts` ### Done when - [ ] A new page is one file with no code change - [ ] Opt-ins record source page and time - [ ] Duplicate addresses update rather than duplicate - [ ] Confirmation and unsubscribe are separate states ### Watch 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. ## Phase 2 · A checkout that records what you promised Buying records the price, the tax shown, and the consent wording the buyer agreed to. ### Steps 1. Show price and tax before the payment, not after A surprise at the card screen is a refund request in a week. Files: `src/checkout/session.ts` 2. Store the consent wording verbatim on the order A link to your terms is not a record of what was agreed. Files: `src/checkout/consent.ts`, `tests/consent.test.ts` 3. Handle the buyer who declines They keep the cancellation right, and your flow should say so. Files: `src/checkout/session.ts` ### Done when - [ ] Consent wording is stored verbatim and immutable - [ ] Displayed price and tax match the stored amounts - [ ] A receipt is sent and recorded - [ ] Declining the acknowledgement is a supported path ### Watch 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. ## Phase 3 · A webhook you can replay Payment events are verified, stored raw, processed exactly once, and safe to replay. ### Steps 1. Verify the signature before parsing anything An unverified body is a stranger's instruction. Files: `src/webhook/verify.ts` 2. Make the processor event identifier unique The database constraint is the idempotency mechanism. Files: `migrations/003_events.sql`, `tests/idempotency.test.ts` 3. Provide a replay command and use it in tests Being able to rebuild state from events is what saves you later. Files: `src/webhook/replay.ts` ```sh npm run replay -- --since=2026-09-01 ``` ### Done when - [ ] The same event three times produces one of everything - [ ] Invalid signatures are rejected before parsing - [ ] Unknown-object events are stored and retried - [ ] Replay reproduces the same ledger ### Watch 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. ## Phase 4 · Access as a ledger, and what a refund means Granting, refunding and disputing all write rows, and the course reads the sum of them. ### Steps 1. Write grant and revoke rows, never a flag History is the thing you will need and cannot reconstruct. Files: `src/entitlements/ledger.ts`, `tests/ledger.test.ts` 2. Reverse the commission in the same transaction Otherwise you pay a commission on a refunded sale. Files: `src/affiliates/commissions.ts` 3. Put consent, events and access on one screen This screen is what you open when somebody is upset. Files: `src/app/admin/orders/[id]/page.tsx` ### Done when - [ ] Access is computed from the ledger, never a flag - [ ] Refunds revoke access and reverse the commission together - [ ] Refund then repurchase leaves correct access throughout - [ ] The order screen shows consent, events and access ### Watch 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. ## Not in this build - Drag and drop page and email editors. - Affiliate payouts. - Webinars and a blog. - Working out your tax registrations for you. ## After v1, if you want it - 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 ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Contacts, orders, events, entitlements and sends. This is the business. DATABASE_PATH=./data/app.db # Required · secret. Server side only. Never in a page, never in a log. PROCESSOR_SECRET_KEY=sk_live_...redacted # Required · secret. Verifies every incoming event before the body is parsed. PROCESSOR_WEBHOOK_SECRET=whsec_...redacted # Required · secret. Receipts, sequences and broadcasts. Verify the sending domain first. EMAIL_API_KEY=re_...redacted # Required · secret. The single operator login. There are no other accounts. ADMIN_PASSWORD=a long random string, redacted # Required. Used in checkout return links, magic links and unsubscribe links. APP_URL=https://school.example # Required. Stamped on every order alongside the wording itself. CONSENT_TEXT_VERSION=2026-09-15 # Required. First-party attribution window for affiliate codes. REFERRAL_COOKIE_DAYS=60
You are building a production product version of Systeme.io. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Systeme.io · product brief ## Problem None of the individual pieces here are hard: a landing page, an opt-in form, a contacts table, a Stripe checkout, a gated video page, a ref-code cookie. An agent can wire all of that into one self-hosted app in a weekend and you will own the list outright. What you cannot one-shot is the boring operational layer: domain authentication, bounce and complaint handling, unsubscribe compliance, and the fact that sending 20,000 emails from your own setup means you now care about deliverability as a personal hobby. You also lose the drag-and-drop editor, which sounds cosmetic until you want to change a headline at 11pm without opening an editor. Verdict lands at kinda because the value survives the rebuild but the maintenance does not disappear. ## Product outcome A shop for one person where the contact list is yours, the payment webhook can be replayed without double granting anything, access is a readable history rather than a flag, and the consent behind every sale is on the order. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Stripe account for checkout and webhooks - Transactional email provider with a verified sending domain, for example Resend or SES - A VPS or serverless host plus your own domain - Video hosting for course content, or accept large local files - Willingness to handle unsubscribe and bounce logic yourself ## Explicit non-goals for v1 - 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 ## Success criteria - The same webhook event three times is confirmed to grant once - An invalid signature is confirmed rejected before parsing - Consent wording is confirmed stored verbatim and unaffected by later edits - A refund is confirmed to revoke access and reverse the commission together - A mid-run unsubscribe is confirmed to stop the send ===== BRIEF.md ===== # Build brief · Systeme.io The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a funnel, a course and a checkout you run yourself, with the contact list in your own database to replace Systeme.io. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two parts of this build involve other people's money and other people's inboxes, and both have rules you do not get to design around. Selling digital access to a consumer comes with a cancellation right. The United Kingdom regulations implementing the European consumer rights directive say a consumer buying digital content not on a physical medium loses the fourteen day right to cancel only if supply began during that period after the consumer gave express consent and acknowledged that the right would be lost. If your checkout did not capture both, the right survives and the argument afterwards is one you lose. So record the consent and the acknowledgement with the order, in the exact words the buyer was shown, and have the refund flow read them. Check the current rules for every country you sell into before writing the copy. Second, keep the list and rent the sending. Owning the contact database is the thing worth rebuilding here. Operating outbound mail yourself is a standing commitment to other people's inbox rules rather than a feature you finish, so send through a provider, and make sure every message carries an unsubscribe that takes effect the moment it is clicked. ### Stack (fixed, do not substitute) - Runtime: One web application, single operator, no multi-tenancy, no accounts beyond a magic link for buyers · you are building a shop for one person and every tenancy feature costs you a week - Content: Pages, lessons and sequences as files in the repository · changing a headline should be a commit, not a database migration - Payments: A hosted checkout session plus a verified webhook, never card details of your own · card handling is a compliance project and the hosted page exists to keep you out of it - Access: Entitlements as grant and revoke rows, never a boolean on the contact · refunds, disputes and repurchases all need a history rather than a flag - Email: A transactional provider for both receipts and broadcasts · the list is the asset worth owning; the sending reputation is not ### Data model (create this before Phase 1) - 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. ### Phase 1 · Pages and opt-ins that are files Build: Pages as files with front matter for slug, title and an optional product. Render server side with one plain layout. An opt-in form posts to your own endpoint, stores the contact with the page it came from, and optionally sends a confirmation link before the contact counts as confirmed. Seed one demo page and one demo contact so the first run shows something working. Trap: 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. Done when: A new page is one file and appears without a code change, tested. An opt-in stores the contact with its source page and creation time, and a duplicate address updates rather than creating a second row, tested. Confirmation state and unsubscribe state are separate columns and neither overwrites the other, asserted in the suite. The first run after seeding shows a working funnel page and one demo contact. Do not build yet: No checkout, entitlements, course gating or email sending yet. ### Phase 2 · A checkout that records what you promised Build: Create a hosted checkout session for a product, with the price and any tax shown before payment. On the page, require the express consent to immediate supply and the acknowledgement that the cancellation right is lost, as a separate tick with its own wording. Store that wording verbatim with the order, along with the time. Send a receipt naming what was bought and how to ask for help. Trap: 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. Done when: An order stores the consent wording verbatim with a timestamp, and editing the site's terms afterwards does not change any stored order, tested. The checkout shows the price and tax before payment and the stored amounts match what was displayed. A receipt is sent naming the product and the support route, recorded in the sends table. A buyer who declines the acknowledgement can still buy, and their order is marked as retaining the cancellation right. Do not build yet: No webhook handling, entitlements, course gating or broadcasts yet. ### Phase 3 · A webhook you can replay Build: Verify the signature before reading the body. Store the raw event keyed on the processor's event identifier with a unique constraint, then process. Processing is a function of the stored event, so replaying it changes nothing. Handle events arriving out of order and events for objects you have never seen. Provide a command to replay a range of events against the current code. Trap: 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. Done when: Delivering the same event three times produces exactly one grant, one receipt and one commission row, tested. An event with an invalid signature is rejected before the body is parsed, tested. An event referring to an unknown order is stored and retried rather than discarded, tested. Replaying a range of stored events against an empty database reproduces the same entitlement ledger, tested. Do not build yet: No refund handling, course gating or broadcasts yet. ### Phase 4 · Access as a ledger, and what a refund means Build: Grant an entitlement on payment, revoke it on refund or dispute, both as rows naming the event that caused them. The course area computes access from the ledger on every request. A refund also marks the commission reversed. Show the operator a per-order view with the consent wording, the events and the current access state on one screen. Trap: 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. Done when: Access is computed from grant and revoke rows on every request, with no boolean anywhere, verified by review. A refund revokes access and reverses the commission in one transaction, tested. A buyer who is refunded and buys again has two grants and one revoke in order, and access is correct at every point, tested. The per-order screen shows the consent wording, every related event and the current access state. Do not build yet: No broadcasts, exports or the operator's exit path yet. ### Phase 5 · Rent the sending, own the list Build: Send receipts, sequences and broadcasts through a provider, recording one row per message with the provider's identifier. Honour an unsubscribe at send time as well as at click time, so a queued sequence stops immediately. Export contacts, orders, entitlements, commissions and send history as CSV and JSON. Provide a per-contact delete that removes the personal data while keeping the financial record you are required to keep. Trap: 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. Done when: A contact who unsubscribes after a broadcast is queued receives nothing from it, tested by unsubscribing mid-run. Every message sent has a row with the provider's identifier and its outcome, checked after a real send. Export produces contacts, orders, entitlements, commissions and sends, and reimports into an empty database, tested. A per-contact delete removes personal fields while leaving the order and its amounts intact, with the redaction recorded. Do not build yet: Nothing after this. ### Out of scope (and why) - Drag and drop page and email editors, which are most of the paid product's surface and are a month of work for a headline you could change in a text file. - Affiliate payouts, which means moving money to other people and brings identity checks, tax forms and a different regulatory conversation. - Webinars and a blog, which are separate products bundled to make the price look better and share nothing with this build. - Working out your own tax registrations, since a calculation service computes a number but does not make you registered anywhere; check the thresholds where your buyers are before your first sale. ### README must contain - That the checkout records the express consent and the acknowledgement of losing the cancellation right, verbatim, on the order, and why a link to your terms is not enough - That the payment webhook is idempotent on the processor's event identifier and that stored events can be replayed to rebuild the entitlement ledger - That access is a ledger of grants and revokes rather than a flag, and what a refund and a dispute each write - That sending is rented rather than operated, and that unsubscribe state is checked again at the moment of each send - What a per-contact delete removes, what it keeps for financial records, and how to export everything ===== ARCHITECTURE.md ===== # Architecture · Systeme.io ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | One web application, single operator, no multi-tenancy, no accounts beyond a magic link for buyers | you are building a shop for one person and every tenancy feature costs you a week | | Content | Pages, lessons and sequences as files in the repository | changing a headline should be a commit, not a database migration | | Payments | A hosted checkout session plus a verified webhook, never card details of your own | card handling is a compliance project and the hosted page exists to keep you out of it | | Access | Entitlements as grant and revoke rows, never a boolean on the contact | refunds, disputes and repurchases all need a history rather than a flag | | Email | A transactional provider for both receipts and broadcasts | the list is the asset worth owning; the sending reputation is not | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Content | Pages, lessons and sequences as files. | Any renderer, provided content stays in version control. | | Checkout | Sessions, displayed price and consent capture. | Any processor, provided the consent record stays verbatim. | | Events | Signature verification, raw storage, idempotency. | Any processor, provided their event identifier is unique in your table. | | Entitlements | Grant and revoke ledger and access computation. | Not swappable for a flag on the contact. | | Email | Provider sending, send log and unsubscribe checks. | Any provider, provided unsubscribe is rechecked at send time. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Contacts, orders, events, entitlements and sends. This is the business. - `PROCESSOR_SECRET_KEY` · required, secret · Server side only. Never in a page, never in a log. - `PROCESSOR_WEBHOOK_SECRET` · required, secret · Verifies every incoming event before the body is parsed. - `EMAIL_API_KEY` · required, secret · Receipts, sequences and broadcasts. Verify the sending domain first. - `ADMIN_PASSWORD` · required, secret · The single operator login. There are no other accounts. - `APP_URL` · required · Used in checkout return links, magic links and unsubscribe links. - `CONSENT_TEXT_VERSION` · required · Stamped on every order alongside the wording itself. - `REFERRAL_COOKIE_DAYS` · required · First-party attribution window for affiliate codes. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Systeme.io product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: One web application, single operator, no multi-tenancy, no accounts beyond a magic link for buyers, Pages, lessons and sequences as files in the repository, A hosted checkout session plus a verified webhook, never card details of your own, Entitlements as grant and revoke rows, never a boolean on the contact, A transactional provider for both receipts and broadcasts. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - 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. - 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. - 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. - 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. - 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. ===== MILESTONES.md ===== # Delivery milestones · Systeme.io Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Pages and opt-ins that are files Funnel and lesson content lives in the repository, and an opt-in writes a contact you own. ### Steps 1. Keep pages and lessons as files Content in git is content you can diff, review and roll back. Files: `content/pages/`, `src/content/load.ts` 2. Record the source page on every contact It is the only context you will have later. Files: `src/contacts/create.ts`, `tests/optin.test.ts` 3. Keep confirmation and subscription separate They answer different questions and conflating them loses both. Files: `src/contacts/state.ts` ### Done when - [ ] A new page is one file with no code change - [ ] Opt-ins record source page and time - [ ] Duplicate addresses update rather than duplicate - [ ] Confirmation and unsubscribe are separate states ### Watch 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. ## M2 · A checkout that records what you promised Buying records the price, the tax shown, and the consent wording the buyer agreed to. ### Steps 1. Show price and tax before the payment, not after A surprise at the card screen is a refund request in a week. Files: `src/checkout/session.ts` 2. Store the consent wording verbatim on the order A link to your terms is not a record of what was agreed. Files: `src/checkout/consent.ts`, `tests/consent.test.ts` 3. Handle the buyer who declines They keep the cancellation right, and your flow should say so. Files: `src/checkout/session.ts` ### Done when - [ ] Consent wording is stored verbatim and immutable - [ ] Displayed price and tax match the stored amounts - [ ] A receipt is sent and recorded - [ ] Declining the acknowledgement is a supported path ### Watch 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. ## M3 · A webhook you can replay Payment events are verified, stored raw, processed exactly once, and safe to replay. ### Steps 1. Verify the signature before parsing anything An unverified body is a stranger's instruction. Files: `src/webhook/verify.ts` 2. Make the processor event identifier unique The database constraint is the idempotency mechanism. Files: `migrations/003_events.sql`, `tests/idempotency.test.ts` 3. Provide a replay command and use it in tests Being able to rebuild state from events is what saves you later. Files: `src/webhook/replay.ts` ```sh npm run replay -- --since=2026-09-01 ``` ### Done when - [ ] The same event three times produces one of everything - [ ] Invalid signatures are rejected before parsing - [ ] Unknown-object events are stored and retried - [ ] Replay reproduces the same ledger ### Watch 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. ## M4 · Access as a ledger, and what a refund means Granting, refunding and disputing all write rows, and the course reads the sum of them. ### Steps 1. Write grant and revoke rows, never a flag History is the thing you will need and cannot reconstruct. Files: `src/entitlements/ledger.ts`, `tests/ledger.test.ts` 2. Reverse the commission in the same transaction Otherwise you pay a commission on a refunded sale. Files: `src/affiliates/commissions.ts` 3. Put consent, events and access on one screen This screen is what you open when somebody is upset. Files: `src/app/admin/orders/[id]/page.tsx` ### Done when - [ ] Access is computed from the ledger, never a flag - [ ] Refunds revoke access and reverse the commission together - [ ] Refund then repurchase leaves correct access throughout - [ ] The order screen shows consent, events and access ### Watch 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. ## M5 · Rent the sending, own the list (production only) Email goes out through a provider, unsubscribes take effect immediately, and everything exports. ### Steps 1. Check unsubscribe state at send time The list you built an hour ago is already out of date. Files: `src/email/send.ts`, `tests/unsubscribe.test.ts` 2. Record one row per message with the provider identifier It is how you answer what somebody did or did not receive. Files: `src/email/log.ts` 3. Export everything, and delete a person properly Owning the list means being able to leave with it, and to remove somebody from it. Files: `src/export/all.ts`, `src/contacts/redact.ts` ```sh npm run export -- --all ``` ### Done when - [ ] A mid-run unsubscribe stops the send - [ ] Every message has a row with an outcome - [ ] Export and reimport round-trip every table - [ ] A per-contact delete redacts without destroying the order ### Watch 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. ===== OPERATIONS.md ===== # Operations · Systeme.io ## Backup The database, frequently, because it holds the list and the order history and neither can be reconstructed from the processor alone. Keep an off-machine copy, and test the restore against a replay of recent events. ## Restore Restore the database, then replay stored events from before the backup point and confirm the entitlement ledger matches the processor's own record of payments and refunds. A restore that leaves somebody with access they were refunded is the failure to look for. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Webhook failures and retries, the gap between orders in your database and payments at the processor, send failures by reason, and the unsubscribe rate after each broadcast. A widening gap against the processor is the first sign the webhook stopped being processed. ## Incident checklist The incidents here are money incidents: access granted twice, a commission paid on a refunded sale, or a refund argued over months later. The raw event store, the entitlement ledger and the stored consent wording are what make each of those a lookup rather than a negotiation. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] The same webhook event three times is confirmed to grant once - [ ] An invalid signature is confirmed rejected before parsing - [ ] Consent wording is confirmed stored verbatim and unaffected by later edits - [ ] A refund is confirmed to revoke access and reverse the commission together - [ ] A mid-run unsubscribe is confirmed to stop the send ## Launch constraint Do not market omitted Systeme.io capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Contacts, orders, events, entitlements and sends. This is the business. DATABASE_PATH=./data/app.db # Required · secret. Server side only. Never in a page, never in a log. PROCESSOR_SECRET_KEY=sk_live_...redacted # Required · secret. Verifies every incoming event before the body is parsed. PROCESSOR_WEBHOOK_SECRET=whsec_...redacted # Required · secret. Receipts, sequences and broadcasts. Verify the sending domain first. EMAIL_API_KEY=re_...redacted # Required · secret. The single operator login. There are no other accounts. ADMIN_PASSWORD=a long random string, redacted # Required. Used in checkout return links, magic links and unsubscribe links. APP_URL=https://school.example # Required. Stamped on every order alongside the wording itself. CONSENT_TEXT_VERSION=2026-09-15 # Required. First-party attribution window for affiliate codes. REFERRAL_COOKIE_DAYS=60
# Systeme.io · indie build 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. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | One web application, single operator, no multi-tenancy, no accounts beyond a magic link for buyers | you are building a shop for one person and every tenancy feature costs you a week | | Content | Pages, lessons and sequences as files in the repository | changing a headline should be a commit, not a database migration | | Payments | A hosted checkout session plus a verified webhook, never card details of your own | card handling is a compliance project and the hosted page exists to keep you out of it | | Access | Entitlements as grant and revoke rows, never a boolean on the contact | refunds, disputes and repurchases all need a history rather than a flag | | Email | A transactional provider for both receipts and broadcasts | the list is the asset worth owning; the sending reputation is not | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the application, the worker and the replay command. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **A payment processor account with hosted checkout and webhooks** · a 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 - [ ] **A transactional email provider with a verified sending domain** · usually 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 - [ ] **A host with a persistent volume and your own domain** · a 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 - [ ] **The exact wording of the consent and acknowledgement at checkout** · free - 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 ## Quick start ```sh npm run replay -- --since=2026-09-01 ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - 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 If one of those is essential to you, that is the reason to keep paying for Systeme.io, and the README should say so rather than pretend.
# Build brief · Systeme.io The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a funnel, a course and a checkout you run yourself, with the contact list in your own database to replace Systeme.io. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two parts of this build involve other people's money and other people's inboxes, and both have rules you do not get to design around. Selling digital access to a consumer comes with a cancellation right. The United Kingdom regulations implementing the European consumer rights directive say a consumer buying digital content not on a physical medium loses the fourteen day right to cancel only if supply began during that period after the consumer gave express consent and acknowledged that the right would be lost. If your checkout did not capture both, the right survives and the argument afterwards is one you lose. So record the consent and the acknowledgement with the order, in the exact words the buyer was shown, and have the refund flow read them. Check the current rules for every country you sell into before writing the copy. Second, keep the list and rent the sending. Owning the contact database is the thing worth rebuilding here. Operating outbound mail yourself is a standing commitment to other people's inbox rules rather than a feature you finish, so send through a provider, and make sure every message carries an unsubscribe that takes effect the moment it is clicked. ### Stack (fixed, do not substitute) - Runtime: One web application, single operator, no multi-tenancy, no accounts beyond a magic link for buyers · you are building a shop for one person and every tenancy feature costs you a week - Content: Pages, lessons and sequences as files in the repository · changing a headline should be a commit, not a database migration - Payments: A hosted checkout session plus a verified webhook, never card details of your own · card handling is a compliance project and the hosted page exists to keep you out of it - Access: Entitlements as grant and revoke rows, never a boolean on the contact · refunds, disputes and repurchases all need a history rather than a flag - Email: A transactional provider for both receipts and broadcasts · the list is the asset worth owning; the sending reputation is not ### Data model (create this before Phase 1) - 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. ### Phase 1 · Pages and opt-ins that are files Build: Pages as files with front matter for slug, title and an optional product. Render server side with one plain layout. An opt-in form posts to your own endpoint, stores the contact with the page it came from, and optionally sends a confirmation link before the contact counts as confirmed. Seed one demo page and one demo contact so the first run shows something working. Trap: 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. Done when: A new page is one file and appears without a code change, tested. An opt-in stores the contact with its source page and creation time, and a duplicate address updates rather than creating a second row, tested. Confirmation state and unsubscribe state are separate columns and neither overwrites the other, asserted in the suite. The first run after seeding shows a working funnel page and one demo contact. Do not build yet: No checkout, entitlements, course gating or email sending yet. ### Phase 2 · A checkout that records what you promised Build: Create a hosted checkout session for a product, with the price and any tax shown before payment. On the page, require the express consent to immediate supply and the acknowledgement that the cancellation right is lost, as a separate tick with its own wording. Store that wording verbatim with the order, along with the time. Send a receipt naming what was bought and how to ask for help. Trap: 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. Done when: An order stores the consent wording verbatim with a timestamp, and editing the site's terms afterwards does not change any stored order, tested. The checkout shows the price and tax before payment and the stored amounts match what was displayed. A receipt is sent naming the product and the support route, recorded in the sends table. A buyer who declines the acknowledgement can still buy, and their order is marked as retaining the cancellation right. Do not build yet: No webhook handling, entitlements, course gating or broadcasts yet. ### Phase 3 · A webhook you can replay Build: Verify the signature before reading the body. Store the raw event keyed on the processor's event identifier with a unique constraint, then process. Processing is a function of the stored event, so replaying it changes nothing. Handle events arriving out of order and events for objects you have never seen. Provide a command to replay a range of events against the current code. Trap: 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. Done when: Delivering the same event three times produces exactly one grant, one receipt and one commission row, tested. An event with an invalid signature is rejected before the body is parsed, tested. An event referring to an unknown order is stored and retried rather than discarded, tested. Replaying a range of stored events against an empty database reproduces the same entitlement ledger, tested. Do not build yet: No refund handling, course gating or broadcasts yet. ### Phase 4 · Access as a ledger, and what a refund means Build: Grant an entitlement on payment, revoke it on refund or dispute, both as rows naming the event that caused them. The course area computes access from the ledger on every request. A refund also marks the commission reversed. Show the operator a per-order view with the consent wording, the events and the current access state on one screen. Trap: 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. Done when: Access is computed from grant and revoke rows on every request, with no boolean anywhere, verified by review. A refund revokes access and reverses the commission in one transaction, tested. A buyer who is refunded and buys again has two grants and one revoke in order, and access is correct at every point, tested. The per-order screen shows the consent wording, every related event and the current access state. Do not build yet: No broadcasts, exports or the operator's exit path yet. ### Phase 5 · Rent the sending, own the list Build: Send receipts, sequences and broadcasts through a provider, recording one row per message with the provider's identifier. Honour an unsubscribe at send time as well as at click time, so a queued sequence stops immediately. Export contacts, orders, entitlements, commissions and send history as CSV and JSON. Provide a per-contact delete that removes the personal data while keeping the financial record you are required to keep. Trap: 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. Done when: A contact who unsubscribes after a broadcast is queued receives nothing from it, tested by unsubscribing mid-run. Every message sent has a row with the provider's identifier and its outcome, checked after a real send. Export produces contacts, orders, entitlements, commissions and sends, and reimports into an empty database, tested. A per-contact delete removes personal fields while leaving the order and its amounts intact, with the redaction recorded. Do not build yet: Nothing after this. ### Out of scope (and why) - Drag and drop page and email editors, which are most of the paid product's surface and are a month of work for a headline you could change in a text file. - Affiliate payouts, which means moving money to other people and brings identity checks, tax forms and a different regulatory conversation. - Webinars and a blog, which are separate products bundled to make the price look better and share nothing with this build. - Working out your own tax registrations, since a calculation service computes a number but does not make you registered anywhere; check the thresholds where your buyers are before your first sale. ### README must contain - That the checkout records the express consent and the acknowledgement of losing the cancellation right, verbatim, on the order, and why a link to your terms is not enough - That the payment webhook is idempotent on the processor's event identifier and that stored events can be replayed to rebuild the entitlement ledger - That access is a ledger of grants and revokes rather than a flag, and what a refund and a dispute each write - That sending is rented rather than operated, and that unsubscribe state is checked again at the moment of each send - What a per-contact delete removes, what it keeps for financial records, and how to export everything
# Agent instructions · Systeme.io indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: One web application, single operator, no multi-tenancy, no accounts beyond a magic link for buyers, Pages, lessons and sequences as files in the repository, A hosted checkout session plus a verified webhook, never card details of your own, Entitlements as grant and revoke rows, never a boolean on the contact, A transactional provider for both receipts and broadcasts. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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. - 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. - 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. - 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.
# Build plan · Systeme.io 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Pages and opt-ins that are files Funnel and lesson content lives in the repository, and an opt-in writes a contact you own. ### Steps 1. Keep pages and lessons as files Content in git is content you can diff, review and roll back. Files: `content/pages/`, `src/content/load.ts` 2. Record the source page on every contact It is the only context you will have later. Files: `src/contacts/create.ts`, `tests/optin.test.ts` 3. Keep confirmation and subscription separate They answer different questions and conflating them loses both. Files: `src/contacts/state.ts` ### Done when - [ ] A new page is one file with no code change - [ ] Opt-ins record source page and time - [ ] Duplicate addresses update rather than duplicate - [ ] Confirmation and unsubscribe are separate states ### Watch 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. ## Phase 2 · A checkout that records what you promised Buying records the price, the tax shown, and the consent wording the buyer agreed to. ### Steps 1. Show price and tax before the payment, not after A surprise at the card screen is a refund request in a week. Files: `src/checkout/session.ts` 2. Store the consent wording verbatim on the order A link to your terms is not a record of what was agreed. Files: `src/checkout/consent.ts`, `tests/consent.test.ts` 3. Handle the buyer who declines They keep the cancellation right, and your flow should say so. Files: `src/checkout/session.ts` ### Done when - [ ] Consent wording is stored verbatim and immutable - [ ] Displayed price and tax match the stored amounts - [ ] A receipt is sent and recorded - [ ] Declining the acknowledgement is a supported path ### Watch 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. ## Phase 3 · A webhook you can replay Payment events are verified, stored raw, processed exactly once, and safe to replay. ### Steps 1. Verify the signature before parsing anything An unverified body is a stranger's instruction. Files: `src/webhook/verify.ts` 2. Make the processor event identifier unique The database constraint is the idempotency mechanism. Files: `migrations/003_events.sql`, `tests/idempotency.test.ts` 3. Provide a replay command and use it in tests Being able to rebuild state from events is what saves you later. Files: `src/webhook/replay.ts` ```sh npm run replay -- --since=2026-09-01 ``` ### Done when - [ ] The same event three times produces one of everything - [ ] Invalid signatures are rejected before parsing - [ ] Unknown-object events are stored and retried - [ ] Replay reproduces the same ledger ### Watch 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. ## Phase 4 · Access as a ledger, and what a refund means Granting, refunding and disputing all write rows, and the course reads the sum of them. ### Steps 1. Write grant and revoke rows, never a flag History is the thing you will need and cannot reconstruct. Files: `src/entitlements/ledger.ts`, `tests/ledger.test.ts` 2. Reverse the commission in the same transaction Otherwise you pay a commission on a refunded sale. Files: `src/affiliates/commissions.ts` 3. Put consent, events and access on one screen This screen is what you open when somebody is upset. Files: `src/app/admin/orders/[id]/page.tsx` ### Done when - [ ] Access is computed from the ledger, never a flag - [ ] Refunds revoke access and reverse the commission together - [ ] Refund then repurchase leaves correct access throughout - [ ] The order screen shows consent, events and access ### Watch 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. ## Not in this build - Drag and drop page and email editors. - Affiliate payouts. - Webinars and a blog. - Working out your tax registrations for you. ## After v1, if you want it - 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
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Contacts, orders, events, entitlements and sends. This is the business. DATABASE_PATH=./data/app.db # Required · secret. Server side only. Never in a page, never in a log. PROCESSOR_SECRET_KEY=sk_live_...redacted # Required · secret. Verifies every incoming event before the body is parsed. PROCESSOR_WEBHOOK_SECRET=whsec_...redacted # Required · secret. Receipts, sequences and broadcasts. Verify the sending domain first. EMAIL_API_KEY=re_...redacted # Required · secret. The single operator login. There are no other accounts. ADMIN_PASSWORD=a long random string, redacted # Required. Used in checkout return links, magic links and unsubscribe links. APP_URL=https://school.example # Required. Stamped on every order alongside the wording itself. CONSENT_TEXT_VERSION=2026-09-15 # Required. First-party attribution window for affiliate codes. REFERRAL_COOKIE_DAYS=60
# Systeme.io · product brief ## Problem None of the individual pieces here are hard: a landing page, an opt-in form, a contacts table, a Stripe checkout, a gated video page, a ref-code cookie. An agent can wire all of that into one self-hosted app in a weekend and you will own the list outright. What you cannot one-shot is the boring operational layer: domain authentication, bounce and complaint handling, unsubscribe compliance, and the fact that sending 20,000 emails from your own setup means you now care about deliverability as a personal hobby. You also lose the drag-and-drop editor, which sounds cosmetic until you want to change a headline at 11pm without opening an editor. Verdict lands at kinda because the value survives the rebuild but the maintenance does not disappear. ## Product outcome A shop for one person where the contact list is yours, the payment webhook can be replayed without double granting anything, access is a readable history rather than a flag, and the consent behind every sale is on the order. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Stripe account for checkout and webhooks - Transactional email provider with a verified sending domain, for example Resend or SES - A VPS or serverless host plus your own domain - Video hosting for course content, or accept large local files - Willingness to handle unsubscribe and bounce logic yourself ## Explicit non-goals for v1 - 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 ## Success criteria - The same webhook event three times is confirmed to grant once - An invalid signature is confirmed rejected before parsing - Consent wording is confirmed stored verbatim and unaffected by later edits - A refund is confirmed to revoke access and reverse the commission together - A mid-run unsubscribe is confirmed to stop the send
# Build brief · Systeme.io The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a funnel, a course and a checkout you run yourself, with the contact list in your own database to replace Systeme.io. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two parts of this build involve other people's money and other people's inboxes, and both have rules you do not get to design around. Selling digital access to a consumer comes with a cancellation right. The United Kingdom regulations implementing the European consumer rights directive say a consumer buying digital content not on a physical medium loses the fourteen day right to cancel only if supply began during that period after the consumer gave express consent and acknowledged that the right would be lost. If your checkout did not capture both, the right survives and the argument afterwards is one you lose. So record the consent and the acknowledgement with the order, in the exact words the buyer was shown, and have the refund flow read them. Check the current rules for every country you sell into before writing the copy. Second, keep the list and rent the sending. Owning the contact database is the thing worth rebuilding here. Operating outbound mail yourself is a standing commitment to other people's inbox rules rather than a feature you finish, so send through a provider, and make sure every message carries an unsubscribe that takes effect the moment it is clicked. ### Stack (fixed, do not substitute) - Runtime: One web application, single operator, no multi-tenancy, no accounts beyond a magic link for buyers · you are building a shop for one person and every tenancy feature costs you a week - Content: Pages, lessons and sequences as files in the repository · changing a headline should be a commit, not a database migration - Payments: A hosted checkout session plus a verified webhook, never card details of your own · card handling is a compliance project and the hosted page exists to keep you out of it - Access: Entitlements as grant and revoke rows, never a boolean on the contact · refunds, disputes and repurchases all need a history rather than a flag - Email: A transactional provider for both receipts and broadcasts · the list is the asset worth owning; the sending reputation is not ### Data model (create this before Phase 1) - 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. ### Phase 1 · Pages and opt-ins that are files Build: Pages as files with front matter for slug, title and an optional product. Render server side with one plain layout. An opt-in form posts to your own endpoint, stores the contact with the page it came from, and optionally sends a confirmation link before the contact counts as confirmed. Seed one demo page and one demo contact so the first run shows something working. Trap: 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. Done when: A new page is one file and appears without a code change, tested. An opt-in stores the contact with its source page and creation time, and a duplicate address updates rather than creating a second row, tested. Confirmation state and unsubscribe state are separate columns and neither overwrites the other, asserted in the suite. The first run after seeding shows a working funnel page and one demo contact. Do not build yet: No checkout, entitlements, course gating or email sending yet. ### Phase 2 · A checkout that records what you promised Build: Create a hosted checkout session for a product, with the price and any tax shown before payment. On the page, require the express consent to immediate supply and the acknowledgement that the cancellation right is lost, as a separate tick with its own wording. Store that wording verbatim with the order, along with the time. Send a receipt naming what was bought and how to ask for help. Trap: 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. Done when: An order stores the consent wording verbatim with a timestamp, and editing the site's terms afterwards does not change any stored order, tested. The checkout shows the price and tax before payment and the stored amounts match what was displayed. A receipt is sent naming the product and the support route, recorded in the sends table. A buyer who declines the acknowledgement can still buy, and their order is marked as retaining the cancellation right. Do not build yet: No webhook handling, entitlements, course gating or broadcasts yet. ### Phase 3 · A webhook you can replay Build: Verify the signature before reading the body. Store the raw event keyed on the processor's event identifier with a unique constraint, then process. Processing is a function of the stored event, so replaying it changes nothing. Handle events arriving out of order and events for objects you have never seen. Provide a command to replay a range of events against the current code. Trap: 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. Done when: Delivering the same event three times produces exactly one grant, one receipt and one commission row, tested. An event with an invalid signature is rejected before the body is parsed, tested. An event referring to an unknown order is stored and retried rather than discarded, tested. Replaying a range of stored events against an empty database reproduces the same entitlement ledger, tested. Do not build yet: No refund handling, course gating or broadcasts yet. ### Phase 4 · Access as a ledger, and what a refund means Build: Grant an entitlement on payment, revoke it on refund or dispute, both as rows naming the event that caused them. The course area computes access from the ledger on every request. A refund also marks the commission reversed. Show the operator a per-order view with the consent wording, the events and the current access state on one screen. Trap: 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. Done when: Access is computed from grant and revoke rows on every request, with no boolean anywhere, verified by review. A refund revokes access and reverses the commission in one transaction, tested. A buyer who is refunded and buys again has two grants and one revoke in order, and access is correct at every point, tested. The per-order screen shows the consent wording, every related event and the current access state. Do not build yet: No broadcasts, exports or the operator's exit path yet. ### Phase 5 · Rent the sending, own the list Build: Send receipts, sequences and broadcasts through a provider, recording one row per message with the provider's identifier. Honour an unsubscribe at send time as well as at click time, so a queued sequence stops immediately. Export contacts, orders, entitlements, commissions and send history as CSV and JSON. Provide a per-contact delete that removes the personal data while keeping the financial record you are required to keep. Trap: 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. Done when: A contact who unsubscribes after a broadcast is queued receives nothing from it, tested by unsubscribing mid-run. Every message sent has a row with the provider's identifier and its outcome, checked after a real send. Export produces contacts, orders, entitlements, commissions and sends, and reimports into an empty database, tested. A per-contact delete removes personal fields while leaving the order and its amounts intact, with the redaction recorded. Do not build yet: Nothing after this. ### Out of scope (and why) - Drag and drop page and email editors, which are most of the paid product's surface and are a month of work for a headline you could change in a text file. - Affiliate payouts, which means moving money to other people and brings identity checks, tax forms and a different regulatory conversation. - Webinars and a blog, which are separate products bundled to make the price look better and share nothing with this build. - Working out your own tax registrations, since a calculation service computes a number but does not make you registered anywhere; check the thresholds where your buyers are before your first sale. ### README must contain - That the checkout records the express consent and the acknowledgement of losing the cancellation right, verbatim, on the order, and why a link to your terms is not enough - That the payment webhook is idempotent on the processor's event identifier and that stored events can be replayed to rebuild the entitlement ledger - That access is a ledger of grants and revokes rather than a flag, and what a refund and a dispute each write - That sending is rented rather than operated, and that unsubscribe state is checked again at the moment of each send - What a per-contact delete removes, what it keeps for financial records, and how to export everything
# Architecture · Systeme.io ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | One web application, single operator, no multi-tenancy, no accounts beyond a magic link for buyers | you are building a shop for one person and every tenancy feature costs you a week | | Content | Pages, lessons and sequences as files in the repository | changing a headline should be a commit, not a database migration | | Payments | A hosted checkout session plus a verified webhook, never card details of your own | card handling is a compliance project and the hosted page exists to keep you out of it | | Access | Entitlements as grant and revoke rows, never a boolean on the contact | refunds, disputes and repurchases all need a history rather than a flag | | Email | A transactional provider for both receipts and broadcasts | the list is the asset worth owning; the sending reputation is not | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Content | Pages, lessons and sequences as files. | Any renderer, provided content stays in version control. | | Checkout | Sessions, displayed price and consent capture. | Any processor, provided the consent record stays verbatim. | | Events | Signature verification, raw storage, idempotency. | Any processor, provided their event identifier is unique in your table. | | Entitlements | Grant and revoke ledger and access computation. | Not swappable for a flag on the contact. | | Email | Provider sending, send log and unsubscribe checks. | Any provider, provided unsubscribe is rechecked at send time. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Contacts, orders, events, entitlements and sends. This is the business. - `PROCESSOR_SECRET_KEY` · required, secret · Server side only. Never in a page, never in a log. - `PROCESSOR_WEBHOOK_SECRET` · required, secret · Verifies every incoming event before the body is parsed. - `EMAIL_API_KEY` · required, secret · Receipts, sequences and broadcasts. Verify the sending domain first. - `ADMIN_PASSWORD` · required, secret · The single operator login. There are no other accounts. - `APP_URL` · required · Used in checkout return links, magic links and unsubscribe links. - `CONSENT_TEXT_VERSION` · required · Stamped on every order alongside the wording itself. - `REFERRAL_COOKIE_DAYS` · required · First-party attribution window for affiliate codes. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Systeme.io product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: One web application, single operator, no multi-tenancy, no accounts beyond a magic link for buyers, Pages, lessons and sequences as files in the repository, A hosted checkout session plus a verified webhook, never card details of your own, Entitlements as grant and revoke rows, never a boolean on the contact, A transactional provider for both receipts and broadcasts. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - 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. - 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. - 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. - 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. - 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.
# Delivery milestones · Systeme.io Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Pages and opt-ins that are files Funnel and lesson content lives in the repository, and an opt-in writes a contact you own. ### Steps 1. Keep pages and lessons as files Content in git is content you can diff, review and roll back. Files: `content/pages/`, `src/content/load.ts` 2. Record the source page on every contact It is the only context you will have later. Files: `src/contacts/create.ts`, `tests/optin.test.ts` 3. Keep confirmation and subscription separate They answer different questions and conflating them loses both. Files: `src/contacts/state.ts` ### Done when - [ ] A new page is one file with no code change - [ ] Opt-ins record source page and time - [ ] Duplicate addresses update rather than duplicate - [ ] Confirmation and unsubscribe are separate states ### Watch 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. ## M2 · A checkout that records what you promised Buying records the price, the tax shown, and the consent wording the buyer agreed to. ### Steps 1. Show price and tax before the payment, not after A surprise at the card screen is a refund request in a week. Files: `src/checkout/session.ts` 2. Store the consent wording verbatim on the order A link to your terms is not a record of what was agreed. Files: `src/checkout/consent.ts`, `tests/consent.test.ts` 3. Handle the buyer who declines They keep the cancellation right, and your flow should say so. Files: `src/checkout/session.ts` ### Done when - [ ] Consent wording is stored verbatim and immutable - [ ] Displayed price and tax match the stored amounts - [ ] A receipt is sent and recorded - [ ] Declining the acknowledgement is a supported path ### Watch 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. ## M3 · A webhook you can replay Payment events are verified, stored raw, processed exactly once, and safe to replay. ### Steps 1. Verify the signature before parsing anything An unverified body is a stranger's instruction. Files: `src/webhook/verify.ts` 2. Make the processor event identifier unique The database constraint is the idempotency mechanism. Files: `migrations/003_events.sql`, `tests/idempotency.test.ts` 3. Provide a replay command and use it in tests Being able to rebuild state from events is what saves you later. Files: `src/webhook/replay.ts` ```sh npm run replay -- --since=2026-09-01 ``` ### Done when - [ ] The same event three times produces one of everything - [ ] Invalid signatures are rejected before parsing - [ ] Unknown-object events are stored and retried - [ ] Replay reproduces the same ledger ### Watch 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. ## M4 · Access as a ledger, and what a refund means Granting, refunding and disputing all write rows, and the course reads the sum of them. ### Steps 1. Write grant and revoke rows, never a flag History is the thing you will need and cannot reconstruct. Files: `src/entitlements/ledger.ts`, `tests/ledger.test.ts` 2. Reverse the commission in the same transaction Otherwise you pay a commission on a refunded sale. Files: `src/affiliates/commissions.ts` 3. Put consent, events and access on one screen This screen is what you open when somebody is upset. Files: `src/app/admin/orders/[id]/page.tsx` ### Done when - [ ] Access is computed from the ledger, never a flag - [ ] Refunds revoke access and reverse the commission together - [ ] Refund then repurchase leaves correct access throughout - [ ] The order screen shows consent, events and access ### Watch 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. ## M5 · Rent the sending, own the list (production only) Email goes out through a provider, unsubscribes take effect immediately, and everything exports. ### Steps 1. Check unsubscribe state at send time The list you built an hour ago is already out of date. Files: `src/email/send.ts`, `tests/unsubscribe.test.ts` 2. Record one row per message with the provider identifier It is how you answer what somebody did or did not receive. Files: `src/email/log.ts` 3. Export everything, and delete a person properly Owning the list means being able to leave with it, and to remove somebody from it. Files: `src/export/all.ts`, `src/contacts/redact.ts` ```sh npm run export -- --all ``` ### Done when - [ ] A mid-run unsubscribe stops the send - [ ] Every message has a row with an outcome - [ ] Export and reimport round-trip every table - [ ] A per-contact delete redacts without destroying the order ### Watch 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.
# Operations · Systeme.io ## Backup The database, frequently, because it holds the list and the order history and neither can be reconstructed from the processor alone. Keep an off-machine copy, and test the restore against a replay of recent events. ## Restore Restore the database, then replay stored events from before the backup point and confirm the entitlement ledger matches the processor's own record of payments and refunds. A restore that leaves somebody with access they were refunded is the failure to look for. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Webhook failures and retries, the gap between orders in your database and payments at the processor, send failures by reason, and the unsubscribe rate after each broadcast. A widening gap against the processor is the first sign the webhook stopped being processed. ## Incident checklist The incidents here are money incidents: access granted twice, a commission paid on a refunded sale, or a refund argued over months later. The raw event store, the entitlement ledger and the stored consent wording are what make each of those a lookup rather than a negotiation. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] The same webhook event three times is confirmed to grant once - [ ] An invalid signature is confirmed rejected before parsing - [ ] Consent wording is confirmed stored verbatim and unaffected by later edits - [ ] A refund is confirmed to revoke access and reverse the commission together - [ ] A mid-run unsubscribe is confirmed to stop the send ## Launch constraint Do not market omitted Systeme.io capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Contacts, orders, events, entitlements and sends. This is the business. DATABASE_PATH=./data/app.db # Required · secret. Server side only. Never in a page, never in a log. PROCESSOR_SECRET_KEY=sk_live_...redacted # Required · secret. Verifies every incoming event before the body is parsed. PROCESSOR_WEBHOOK_SECRET=whsec_...redacted # Required · secret. Receipts, sequences and broadcasts. Verify the sending domain first. EMAIL_API_KEY=re_...redacted # Required · secret. The single operator login. There are no other accounts. ADMIN_PASSWORD=a long random string, redacted # Required. Used in checkout return links, magic links and unsubscribe links. APP_URL=https://school.example # Required. Stamped on every order alongside the wording itself. CONSENT_TEXT_VERSION=2026-09-15 # Required. First-party attribution window for affiliate codes. REFERRAL_COOKIE_DAYS=60
$ choose a build depth, inspect the files, then open the complete pack in your agent
Because the business being run on top of it is usually worth more per hour than the infrastructure underneath it. A solo course seller wants to change a price, add an upsell, and send a broadcast in the same twenty minutes, and a bundled tool does that with no deploy step and no deliverability anxiety. The generous free tier also means many users are paying only once the thing is already making money, which is a hard offer to argue with. Self-hosting makes sense if you are technical, plan to stay small, and specifically want to own the contact list and the checkout data.
xVisual page and email editors, plus the template library that makes launching a funnel a copy-paste job
xManaged deliverability: shared sending reputation, bounce suppression, spam complaint handling
xAffiliate program plumbing at the level people expect, including payouts and dashboards for your affiliates
xWebinars, blogs, and the odd extras that come free in the bundle
xOne vendor to blame when checkout breaks on launch day
Nothing worth pointing at. That's why the prompt exists.
Vibecode Systeme.io
Kinda. The core of Systeme.io is buildable in a weekend with the prompt on this page, but there are real gaps: 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. Read the honest list above before committing.
How much does Systeme.io cost?
Systeme.io costs about $17/month (Startup, checked 2026-08-18), which is $204 per year.
What do I lose by replacing Systeme.io?
Honestly: 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. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Systeme.io?
No mature open-source alternative worth pointing at, which is exactly why the one-shot prompt on this page exists.