Build Systeme.io

KINDAreplaces $17/mosaves $204/yrback to the verdict

0%0 of 17 items done

Saved on this device only. Tick prerequisites first, then work the phases in order · do not start one until the checks above it pass.

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-daythe files for this build are in the project pack

RuntimeOne web application, single operator, no multi-tenancy, no accounts beyond a magic link for buyersContentPages, lessons and sequences as files in the repositoryPaymentsA hosted checkout session plus a verified webhook, never card details of your ownAccessEntitlements as grant and revoke rows, never a boolean on the contactEmailA transactional provider for both receipts and broadcasts

Before step 1

Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.

  1. installfree

    Why Runs the application, the worker and the replay command.

    Get it nodejs.org, or your version manager. open ↗

    Verify node --version prints v22 or higher

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

  3. API keyusually free at small volume

    Why Receipts and sequences go through it, and verifying the domain is the one setup step that cannot be skipped.

    Get it Create an account, verify the domain by adding the records it asks for, and take an API key.

    Verify A receipt arrives and the provider reports it delivered

  4. have readya few pounds a month

    Why The database holds your contact list and your order history, so a platform that resets its disk on deploy is not an option.

    Get it Any small virtual machine with a volume, plus a domain and a certificate.

    Verify A restart preserves the database and the webhook endpoint is reachable over your domain

  5. decidefree

    Why This wording is what a refund dispute turns on, and writing it once and storing it per order is the difference between a record and an argument.

    Get it Write both sentences, check the current rules for the countries you sell into, and version the text.

    Verify An order row stores the wording and the version, and changing the site's terms does not alter it

Data model

Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.

- contacts(id, email, name, source_page, created_at, confirmed_at, unsubscribed_at, tags_json)
- orders(id, contact_id, session_id, product_id, amount_minor, tax_minor, currency, state, consent_text, consent_at) · consent_text is the exact wording the buyer was shown
- events(id, processor_event_id, type, received_at, payload_json, processed_at, result) · processor_event_id is unique, and that uniqueness is the whole idempotency story
- entitlements(id, contact_id, product_id, granted_by_event, granted_at, revoked_by_event, revoked_at) · a ledger, so access is a sum rather than a flag
- progress(contact_id, lesson_id, completed_at) and lessons defined by files rather than rows
- referrals(code, contact_id, first_seen_at) and commissions(id, order_id, code, amount_minor, state) · recorded, never paid out by this application
- sends(id, contact_id, kind, subject, provider_message_id, sent_at, failed_reason) · one row per message, which is how you answer what somebody received
- There is no boolean saying a person has access. Access is the sum of grant and revoke rows, so a refund followed by a repurchase leaves a history you can read.

Environment variables

These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.

VariableNeededExampleWhere the value comes from
DATABASE_PATHrequired./data/app.dbContacts, orders, events, entitlements and sends. This is the business.
PROCESSOR_SECRET_KEYsecretrequiredsk_live_...redactedServer side only. Never in a page, never in a log.
PROCESSOR_WEBHOOK_SECRETsecretrequiredwhsec_...redactedVerifies every incoming event before the body is parsed.
EMAIL_API_KEYsecretrequiredre_...redactedReceipts, sequences and broadcasts. Verify the sending domain first.
ADMIN_PASSWORDsecretrequireda long random string, redactedThe single operator login. There are no other accounts.
APP_URLrequiredhttps://school.exampleUsed in checkout return links, magic links and unsubscribe links.
CONSENT_TEXT_VERSIONrequired2026-09-15Stamped on every order alongside the wording itself.
REFERRAL_COOKIE_DAYSrequired60First-party attribution window for affiliate codes.

The build, in order

  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.

    1. Content in git is content you can diff, review and roll back.

      Files content/pages/src/content/load.ts

    2. It is the only context you will have later.

      Files src/contacts/create.tstests/optin.test.ts

    3. They answer different questions and conflating them loses both.

      Files src/contacts/state.ts

    done when · tick each as it passes
    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.
  2. A checkout that records what you promised

    Buying records the price, the tax shown, and the consent wording the buyer agreed to.

    1. A surprise at the card screen is a refund request in a week.

      Files src/checkout/session.ts

    2. A link to your terms is not a record of what was agreed.

      Files src/checkout/consent.tstests/consent.test.ts

    3. They keep the cancellation right, and your flow should say so.

      Files src/checkout/session.ts

    done when · tick each as it passes
    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.
  3. A webhook you can replay

    Payment events are verified, stored raw, processed exactly once, and safe to replay.

    1. An unverified body is a stranger's instruction.

      Files src/webhook/verify.ts

    2. The database constraint is the idempotency mechanism.

      Files migrations/003_events.sqltests/idempotency.test.ts

    3. Being able to rebuild state from events is what saves you later.

      Files src/webhook/replay.ts

      terminal
      npm run replay -- --since=2026-09-01
    done when · tick each as it passes
    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.
  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.

    1. History is the thing you will need and cannot reconstruct.

      Files src/entitlements/ledger.tstests/ledger.test.ts

    2. Otherwise you pay a commission on a refunded sale.

      Files src/affiliates/commissions.ts

    3. This screen is what you open when somebody is upset.

      Files src/app/admin/orders/[id]/page.tsx

    done when · tick each as it passes
    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.
what this build does not replace
after v1, if you want it

Need the files? The project pack on the verdict page hands your agent the whole brief · more creator commerce.