Build Thinkific

KINDAreplaces $54/mosaves $648/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 course delivery platform where the engineering is video at scale and the discipline is entitlement: what a student bought, for how long, and what happens to their progress when a payment fails, a course is updated, or you decide to stop selling it.

estimated effort multi-daythe files for this build are in the project pack

VideoA hosting provider with signed playback URLsEntitlementWhat was purchased, recorded as data with its own lifetimeProgressPer lesson, tied to the student rather than the enrolmentPaymentsHosted checkout with subscriptions and one-off purchasesRuntimeTypeScript with PostgreSQL

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 server and the webhook handlers.

    Get it nodejs.org, or nvm install 22. open ↗

    Verify node --version prints v22 or higher

  2. installfree locally

    Why Entitlements, progress and payments with real constraints.

    Get it Docker or your package manager. open ↗

    Verify psql connects and creates a table

  3. usage based; a course with real students costs real money

    Why Encoding, adaptive streaming and delivery are a substantial project, and signed short-lived URLs are the part that matters for access control.

    Get it A provider supporting per-viewer signed playback URLs. Check the bandwidth pricing before uploading a course.

    Verify A video plays through a signed URL and the URL stops working after expiry

  4. free to test; per-transaction in production

    Why Subscriptions, failed renewals and chargebacks all behave differently from a single charge and only a real provider shows you how.

    Get it Sign up, use test keys, configure a webhook endpoint, and deliberately trigger a failed renewal in test mode. open ↗

    Verify A purchase, a subscription renewal and a failed renewal all produce the right entitlement state

  5. decidefree

    Why What a refund, a chargeback and a retirement do to access are promises to students, and deciding them after someone asks is deciding them badly.

    Get it Write down what lifetime means, what a refund removes, how long a grace period lasts, and what happens if you stop selling a course.

    Verify A written policy covers refund, chargeback, renewal failure and retirement

Data model

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

- courses(id, title, published_version_id, status)
- course_versions(id, course_id, structure_json, published_at) · content changes and students bought a version
- lessons(id, course_version_id, ordinal, kind, video_asset_id, body, duration_seconds, drip_offset_days)
- students(id, email, created_at)
- entitlements(id, student_id, course_id, kind, granted_at, expires_at, revoked_at, source_payment_id) · kind is lifetime, subscription or trial
- progress(student_id, lesson_key, completed_at, seconds_watched) · keyed on a stable lesson key across versions
- payments(id, student_id, kind, amount_minor, provider_ref, status, at)
- Progress is keyed on a stable lesson key rather than a version-specific id, so updating a course does not reset everyone.

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_URLsecretrequiredpostgres://localhost:5432/coursesStudents, entitlements and progress.
VIDEO_PROVIDER_KEYsecretrequireda provider keyUploads and signs playback URLs.
PLAYBACK_URL_TTL_SECONDSrequired300Short enough that a shared link is useless, long enough not to interrupt playback.
RENEWAL_GRACE_DAYSrequired7An expired card is not a cancellation.
RETIRE_PRESERVES_ACCESSrequiredalwaysPresent as a constant. Lifetime access means after you stop selling too.
STRIPE_WEBHOOK_SECRETsecretrequiredwhsec_...Entitlements are granted from webhooks, so an unverified endpoint grants access.

The build, in order

  1. Video that plays and cannot be trivially taken

    Lessons stream reliably and a playback link is not a permanent copy.

    1. Self-hosting video for a course is a project of its own.

      Files src/video.ts

    2. It stops casual sharing, which is what actually happens.

      Files src/playback.ts

    3. It costs a lot, hurts legitimate students, and stops nobody determined.

      Files docs/piracy-position.md

    done when · tick each as it passes
    watch out
    • Building digital rights management to stop copying costs enormous effort, degrades playback for legitimate students, and does not stop anyone determined, since a screen recorder exists. Use short-lived signed URLs to stop casual link sharing, watermark with the student identity if you must, and spend the remaining effort on the course.
  2. Entitlement as the product

    What a student may access is a record, not a side effect of a payment row.

    1. Access is the product. A payment is how it was acquired.

      Files src/entitlements.ts

    2. Support cases and refunds both need it, and both should be recorded.

      Files src/entitlements.ts

    3. A student with two reasons to have access should have access.

      Files src/access.tssrc/access.test.ts

    done when · tick each as it passes
    watch out
    • Deriving access from the payment record means a refund, a chargeback or a failed renewal removes access instantly and sometimes wrongly, and a student who paid a year ago loses a course they own because a payment row changed state. Make the entitlement the thing, granted by a payment but with its own lifetime.
  3. Payments, including when they fail

    Purchases and subscriptions work, and a failure does not lose a student.

    1. A student who pays and closes the tab has still paid.

      Files src/webhooks.ts

    2. An expired card is not a cancellation.

      Files src/renewals.ts

    3. Different causes, different responses, different records.

      Files src/payments.ts

    done when · tick each as it passes
    watch out
    • Revoking access the instant a renewal fails locks out a student whose card simply expired, at the moment they were about to fix it, which converts a payment problem into a cancellation. Grace the failure, tell them, and revoke only after the grace period.
  4. Progress that survives the course changing

    Updating a course does not reset anyone's progress.

    1. Version-specific ids reset everyone on republish.

      Files src/progress.tstests/republish.test.ts

    2. Enrolled students should see improvements, not a reset.

      Files src/versions.ts

    3. Both happen. Both need an answer before the first update.

      Files src/progress.ts

    done when · tick each as it passes
    watch out
    • Keying progress on lesson identifiers that change when the course is republished resets every student to zero, and they will not tell you, they will simply not come back. Use a stable key that survives editing, and decide explicitly what happens when a lesson is split or removed.
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.