Vibecode Thinkific
track this build4 phases, 12 steps, beginner friendly0%The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Thinkific, host a structured course with lessons, progress, and simple quizzes. The hard boundary is course builder, payments, communities, apps, integrations, and support, plus payments, tax, distribution, and operations.
You are building a lean indie version of Thinkific. 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 ===== # Thinkific · indie build 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-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 | | --- | --- | --- | | Video | A hosting provider with signed playback URLs | self-hosting video for a course is a bandwidth and encoding project | | Entitlement | What was purchased, recorded as data with its own lifetime | access is the product and it has to survive everything else | | Progress | Per lesson, tied to the student rather than the enrolment | a student who re-enrols should not lose two months of progress | | Payments | Hosted checkout with subscriptions and one-off purchases | both models coexist and behave differently on failure | | Runtime | TypeScript with PostgreSQL | enrolments, progress and payments are relational | ## 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 server and the webhook handlers. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: Entitlements, progress and payments with real constraints. - Get it: Docker or your package manager. - Verify: psql connects and creates a table - [ ] **A video hosting provider with signed playback** · 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 - [ ] **A payment provider in test mode** · 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. - Verify: A purchase, a subscription renewal and a failed renewal all produce the right entitlement state - [ ] **Your access policy, written before selling** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Digital rights management. - Live cohorts and community features. - Marketing funnels, affiliates and upsells. - Cross-jurisdiction tax handling. - course builder, payments, communities, apps, integrations, and support - marketplace discovery - tax remittance - fraud operations - advanced course and community tooling If one of those is essential to you, that is the reason to keep paying for Thinkific, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Thinkific 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 course platform where students keep access to what they paid for to replace Thinkific. 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. ### Stack (fixed, do not substitute) - Video: A hosting provider with signed playback URLs · self-hosting video for a course is a bandwidth and encoding project - Entitlement: What was purchased, recorded as data with its own lifetime · access is the product and it has to survive everything else - Progress: Per lesson, tied to the student rather than the enrolment · a student who re-enrols should not lose two months of progress - Payments: Hosted checkout with subscriptions and one-off purchases · both models coexist and behave differently on failure - Runtime: TypeScript with PostgreSQL · enrolments, progress and payments are relational ### Data model (create this before Phase 1) - 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. ### Phase 1 · Video that plays and cannot be trivially taken Build: Upload video to a hosting provider with encoding, and serve playback through short-lived signed URLs bound to the student. Handle upload failures and long encodes. Accept that determined copying is not preventable. Trap: 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. Done when: Playback works through short-lived signed URLs bound to the student. A shared URL stops working quickly, tested by copying one. A long encode reports progress rather than appearing broken. An upload interrupted midway resumes rather than restarting. Do not build yet: No enrolment, payment or progress. ### Phase 2 · Entitlement as the product Build: Model entitlements separately from payments, with their own kind, start and expiry. Grant an entitlement on successful payment. Support manual grants and revocations with a recorded reason. Trap: 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. Done when: Access is decided by entitlement rather than payment state, verified by refunding a payment and confirming lifetime access persists per the stated policy. Manual grants and revocations record a reason. An expired entitlement blocks access without deleting progress. Two overlapping entitlements resolve to the more generous. Do not build yet: No payments, drip or progress yet. ### Phase 3 · Payments, including when they fail Build: Hosted checkout for one-off purchases and subscriptions. Grant entitlement on webhook confirmation. Handle a failed renewal with a grace period rather than immediate revocation, and handle a chargeback distinctly from a refund. Trap: 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. Done when: A one-off purchase grants a lifetime entitlement on webhook confirmation, not on redirect. A failed renewal enters a grace period with the student notified rather than losing access immediately. A chargeback is handled distinctly from a voluntary refund. A duplicate webhook does not grant two entitlements. Do not build yet: No progress tracking or content updates yet. ### Phase 4 · Progress that survives the course changing Build: Key progress on a stable lesson key rather than a version-specific id. Publish course versions without disturbing enrolled students. Handle a lesson being removed or split in a new version. Trap: 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. Done when: Republishing a course does not reset student progress, tested by editing and republishing with enrolled students. A removed lesson's progress is retained rather than deleted. A split lesson's progress follows a stated rule. Percentage complete recomputes correctly against the new structure. Do not build yet: No drip, certificates or reporting yet. ### Phase 5 · Drip, completion and closing down gracefully Build: Drip lessons by days since enrolment, computed in the student's zone. Record completion and issue a certificate. Support retiring a course while preserving access for people who already bought it. Trap: Retiring a course by unpublishing it removes access for everyone who bought lifetime access, which is exactly the promise you sold them. Separate stopping sales from removing access, and make retirement default to preserving existing entitlements. Done when: Drip release computes in the student's zone and does not shift across a daylight transition. Retiring a course stops new sales while preserving access for existing entitlements, tested directly. Completion is recorded and a certificate is issued and verifiable. Export contains courses, students, entitlements and progress. Do not build yet: Nothing after this. ### Out of scope (and why) - Digital rights management, which costs a great deal, degrades legitimate playback and does not stop determined copying. - Live cohort features such as scheduled sessions and community, which is a different product layered on top. - Marketing funnels, affiliates and upsells, which is most of what the hosted platforms actually sell. - Tax handling for digital goods across jurisdictions, which is specialist work that changes by territory. ### README must contain - That access is decided by entitlement rather than by payment state, and why a refund does not automatically remove it - That a failed renewal enters a grace period rather than revoking immediately - That progress is keyed on a stable lesson key so republishing does not reset students - That retiring a course stops sales without removing access for people who bought it - That no rights management is implemented, deliberately ===== AGENTS.md ===== # Agent instructions · Thinkific indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: A hosting provider with signed playback URLs, What was purchased, recorded as data with its own lifetime, Per lesson, tied to the student rather than the enrolment, Hosted checkout with subscriptions and one-off purchases, TypeScript with PostgreSQL. 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 - 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. - 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. - 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. - 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. ===== BUILD_PLAN.md ===== # Build plan · Thinkific 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Video that plays and cannot be trivially taken Lessons stream reliably and a playback link is not a permanent copy. ### Steps 1. Use a hosting provider with encoding Self-hosting video for a course is a project of its own. Files: `src/video.ts` 2. Sign playback URLs short and per student It stops casual sharing, which is what actually happens. Files: `src/playback.ts` 3. Do not build rights management It costs a lot, hurts legitimate students, and stops nobody determined. Files: `docs/piracy-position.md` ### Done when - [ ] Playback works through signed URLs - [ ] A shared URL expires quickly - [ ] Long encodes report progress - [ ] Interrupted uploads resume ### 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. ## Phase 2 · Entitlement as the product What a student may access is a record, not a side effect of a payment row. ### Steps 1. Separate entitlement from payment Access is the product. A payment is how it was acquired. Files: `src/entitlements.ts` 2. Support manual grant and revoke with reasons Support cases and refunds both need it, and both should be recorded. Files: `src/entitlements.ts` 3. Resolve overlaps generously A student with two reasons to have access should have access. Files: `src/access.ts`, `src/access.test.ts` ### Done when - [ ] Access derives from entitlement, not payment state - [ ] Manual grants and revocations record reasons - [ ] Expiry blocks access without deleting progress - [ ] Overlapping entitlements resolve generously ### 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. ## Phase 3 · Payments, including when they fail Purchases and subscriptions work, and a failure does not lose a student. ### Steps 1. Grant on webhook, not redirect A student who pays and closes the tab has still paid. Files: `src/webhooks.ts` 2. Grace failed renewals An expired card is not a cancellation. Files: `src/renewals.ts` 3. Distinguish chargeback from refund Different causes, different responses, different records. Files: `src/payments.ts` ### Done when - [ ] Purchase grants on webhook confirmation - [ ] A failed renewal graces and notifies - [ ] Chargebacks are handled distinctly - [ ] Duplicate webhooks grant one entitlement ### 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. ## Phase 4 · Progress that survives the course changing Updating a course does not reset anyone's progress. ### Steps 1. Key progress on a stable lesson key Version-specific ids reset everyone on republish. Files: `src/progress.ts`, `tests/republish.test.ts` 2. Publish versions without disturbing students Enrolled students should see improvements, not a reset. Files: `src/versions.ts` 3. Decide the split and removal rules Both happen. Both need an answer before the first update. Files: `src/progress.ts` ### Done when - [ ] Republishing does not reset progress - [ ] Removed lessons retain their progress - [ ] Split lessons follow a stated rule - [ ] Completion percentage recomputes correctly ### 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. ## Not in this build - Digital rights management. - Live cohorts and community features. - Marketing funnels, affiliates and upsells. - Cross-jurisdiction tax handling. ## After v1, if you want it - Quizzes with attempt limits, which is where completion becomes meaningful - Bundles and multi-course entitlements, once the single-course model is proven - A student-facing receipt and access history, which removes most support questions ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Students, entitlements and progress. DATABASE_URL=postgres://localhost:5432/courses # Required · secret. Uploads and signs playback URLs. VIDEO_PROVIDER_KEY=a provider key # Required. Short enough that a shared link is useless, long enough not to interrupt playback. PLAYBACK_URL_TTL_SECONDS=300 # Required. An expired card is not a cancellation. RENEWAL_GRACE_DAYS=7 # Required. Present as a constant. Lifetime access means after you stop selling too. RETIRE_PRESERVES_ACCESS=always # Required · secret. Entitlements are granted from webhooks, so an unverified endpoint grants access. STRIPE_WEBHOOK_SECRET=whsec_...
You are building a lean indie version of Thinkific. 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 ===== # Thinkific · indie build 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-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 | | --- | --- | --- | | Video | A hosting provider with signed playback URLs | self-hosting video for a course is a bandwidth and encoding project | | Entitlement | What was purchased, recorded as data with its own lifetime | access is the product and it has to survive everything else | | Progress | Per lesson, tied to the student rather than the enrolment | a student who re-enrols should not lose two months of progress | | Payments | Hosted checkout with subscriptions and one-off purchases | both models coexist and behave differently on failure | | Runtime | TypeScript with PostgreSQL | enrolments, progress and payments are relational | ## 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 server and the webhook handlers. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: Entitlements, progress and payments with real constraints. - Get it: Docker or your package manager. - Verify: psql connects and creates a table - [ ] **A video hosting provider with signed playback** · 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 - [ ] **A payment provider in test mode** · 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. - Verify: A purchase, a subscription renewal and a failed renewal all produce the right entitlement state - [ ] **Your access policy, written before selling** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Digital rights management. - Live cohorts and community features. - Marketing funnels, affiliates and upsells. - Cross-jurisdiction tax handling. - course builder, payments, communities, apps, integrations, and support - marketplace discovery - tax remittance - fraud operations - advanced course and community tooling If one of those is essential to you, that is the reason to keep paying for Thinkific, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Thinkific 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 course platform where students keep access to what they paid for to replace Thinkific. 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. ### Stack (fixed, do not substitute) - Video: A hosting provider with signed playback URLs · self-hosting video for a course is a bandwidth and encoding project - Entitlement: What was purchased, recorded as data with its own lifetime · access is the product and it has to survive everything else - Progress: Per lesson, tied to the student rather than the enrolment · a student who re-enrols should not lose two months of progress - Payments: Hosted checkout with subscriptions and one-off purchases · both models coexist and behave differently on failure - Runtime: TypeScript with PostgreSQL · enrolments, progress and payments are relational ### Data model (create this before Phase 1) - 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. ### Phase 1 · Video that plays and cannot be trivially taken Build: Upload video to a hosting provider with encoding, and serve playback through short-lived signed URLs bound to the student. Handle upload failures and long encodes. Accept that determined copying is not preventable. Trap: 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. Done when: Playback works through short-lived signed URLs bound to the student. A shared URL stops working quickly, tested by copying one. A long encode reports progress rather than appearing broken. An upload interrupted midway resumes rather than restarting. Do not build yet: No enrolment, payment or progress. ### Phase 2 · Entitlement as the product Build: Model entitlements separately from payments, with their own kind, start and expiry. Grant an entitlement on successful payment. Support manual grants and revocations with a recorded reason. Trap: 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. Done when: Access is decided by entitlement rather than payment state, verified by refunding a payment and confirming lifetime access persists per the stated policy. Manual grants and revocations record a reason. An expired entitlement blocks access without deleting progress. Two overlapping entitlements resolve to the more generous. Do not build yet: No payments, drip or progress yet. ### Phase 3 · Payments, including when they fail Build: Hosted checkout for one-off purchases and subscriptions. Grant entitlement on webhook confirmation. Handle a failed renewal with a grace period rather than immediate revocation, and handle a chargeback distinctly from a refund. Trap: 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. Done when: A one-off purchase grants a lifetime entitlement on webhook confirmation, not on redirect. A failed renewal enters a grace period with the student notified rather than losing access immediately. A chargeback is handled distinctly from a voluntary refund. A duplicate webhook does not grant two entitlements. Do not build yet: No progress tracking or content updates yet. ### Phase 4 · Progress that survives the course changing Build: Key progress on a stable lesson key rather than a version-specific id. Publish course versions without disturbing enrolled students. Handle a lesson being removed or split in a new version. Trap: 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. Done when: Republishing a course does not reset student progress, tested by editing and republishing with enrolled students. A removed lesson's progress is retained rather than deleted. A split lesson's progress follows a stated rule. Percentage complete recomputes correctly against the new structure. Do not build yet: No drip, certificates or reporting yet. ### Phase 5 · Drip, completion and closing down gracefully Build: Drip lessons by days since enrolment, computed in the student's zone. Record completion and issue a certificate. Support retiring a course while preserving access for people who already bought it. Trap: Retiring a course by unpublishing it removes access for everyone who bought lifetime access, which is exactly the promise you sold them. Separate stopping sales from removing access, and make retirement default to preserving existing entitlements. Done when: Drip release computes in the student's zone and does not shift across a daylight transition. Retiring a course stops new sales while preserving access for existing entitlements, tested directly. Completion is recorded and a certificate is issued and verifiable. Export contains courses, students, entitlements and progress. Do not build yet: Nothing after this. ### Out of scope (and why) - Digital rights management, which costs a great deal, degrades legitimate playback and does not stop determined copying. - Live cohort features such as scheduled sessions and community, which is a different product layered on top. - Marketing funnels, affiliates and upsells, which is most of what the hosted platforms actually sell. - Tax handling for digital goods across jurisdictions, which is specialist work that changes by territory. ### README must contain - That access is decided by entitlement rather than by payment state, and why a refund does not automatically remove it - That a failed renewal enters a grace period rather than revoking immediately - That progress is keyed on a stable lesson key so republishing does not reset students - That retiring a course stops sales without removing access for people who bought it - That no rights management is implemented, deliberately ===== AGENTS.md ===== # Agent instructions · Thinkific indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: A hosting provider with signed playback URLs, What was purchased, recorded as data with its own lifetime, Per lesson, tied to the student rather than the enrolment, Hosted checkout with subscriptions and one-off purchases, TypeScript with PostgreSQL. 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 - 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. - 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. - 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. - 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. ===== BUILD_PLAN.md ===== # Build plan · Thinkific 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Video that plays and cannot be trivially taken Lessons stream reliably and a playback link is not a permanent copy. ### Steps 1. Use a hosting provider with encoding Self-hosting video for a course is a project of its own. Files: `src/video.ts` 2. Sign playback URLs short and per student It stops casual sharing, which is what actually happens. Files: `src/playback.ts` 3. Do not build rights management It costs a lot, hurts legitimate students, and stops nobody determined. Files: `docs/piracy-position.md` ### Done when - [ ] Playback works through signed URLs - [ ] A shared URL expires quickly - [ ] Long encodes report progress - [ ] Interrupted uploads resume ### 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. ## Phase 2 · Entitlement as the product What a student may access is a record, not a side effect of a payment row. ### Steps 1. Separate entitlement from payment Access is the product. A payment is how it was acquired. Files: `src/entitlements.ts` 2. Support manual grant and revoke with reasons Support cases and refunds both need it, and both should be recorded. Files: `src/entitlements.ts` 3. Resolve overlaps generously A student with two reasons to have access should have access. Files: `src/access.ts`, `src/access.test.ts` ### Done when - [ ] Access derives from entitlement, not payment state - [ ] Manual grants and revocations record reasons - [ ] Expiry blocks access without deleting progress - [ ] Overlapping entitlements resolve generously ### 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. ## Phase 3 · Payments, including when they fail Purchases and subscriptions work, and a failure does not lose a student. ### Steps 1. Grant on webhook, not redirect A student who pays and closes the tab has still paid. Files: `src/webhooks.ts` 2. Grace failed renewals An expired card is not a cancellation. Files: `src/renewals.ts` 3. Distinguish chargeback from refund Different causes, different responses, different records. Files: `src/payments.ts` ### Done when - [ ] Purchase grants on webhook confirmation - [ ] A failed renewal graces and notifies - [ ] Chargebacks are handled distinctly - [ ] Duplicate webhooks grant one entitlement ### 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. ## Phase 4 · Progress that survives the course changing Updating a course does not reset anyone's progress. ### Steps 1. Key progress on a stable lesson key Version-specific ids reset everyone on republish. Files: `src/progress.ts`, `tests/republish.test.ts` 2. Publish versions without disturbing students Enrolled students should see improvements, not a reset. Files: `src/versions.ts` 3. Decide the split and removal rules Both happen. Both need an answer before the first update. Files: `src/progress.ts` ### Done when - [ ] Republishing does not reset progress - [ ] Removed lessons retain their progress - [ ] Split lessons follow a stated rule - [ ] Completion percentage recomputes correctly ### 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. ## Not in this build - Digital rights management. - Live cohorts and community features. - Marketing funnels, affiliates and upsells. - Cross-jurisdiction tax handling. ## After v1, if you want it - Quizzes with attempt limits, which is where completion becomes meaningful - Bundles and multi-course entitlements, once the single-course model is proven - A student-facing receipt and access history, which removes most support questions ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Students, entitlements and progress. DATABASE_URL=postgres://localhost:5432/courses # Required · secret. Uploads and signs playback URLs. VIDEO_PROVIDER_KEY=a provider key # Required. Short enough that a shared link is useless, long enough not to interrupt playback. PLAYBACK_URL_TTL_SECONDS=300 # Required. An expired card is not a cancellation. RENEWAL_GRACE_DAYS=7 # Required. Present as a constant. Lifetime access means after you stop selling too. RETIRE_PRESERVES_ACCESS=always # Required · secret. Entitlements are granted from webhooks, so an unverified endpoint grants access. STRIPE_WEBHOOK_SECRET=whsec_...
You are building a production product version of Thinkific. 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 ===== # Thinkific · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Thinkific, host a structured course with lessons, progress, and simple quizzes. The hard boundary is course builder, payments, communities, apps, integrations, and support, plus payments, tax, distribution, and operations. ## Product outcome A course platform where students keep what they bought through refunds, republishes and retirements, and where a card expiring does not cost you the student. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Stripe account - PostgreSQL - S3-compatible storage - public HTTPS deployment - email provider ## Explicit non-goals for v1 - Digital rights management. - Live cohorts and community features. - Marketing funnels, affiliates and upsells. - Cross-jurisdiction tax handling. - course builder, payments, communities, apps, integrations, and support - marketplace discovery - tax remittance - fraud operations - advanced course and community tooling ## Success criteria - Access is confirmed to derive from entitlement rather than payment state - A failed renewal is confirmed to grace rather than revoke - Republishing is confirmed not to reset progress - Retiring is confirmed to preserve existing access - A shared playback URL is confirmed to expire quickly ===== BRIEF.md ===== # Build brief · Thinkific 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 course platform where students keep access to what they paid for to replace Thinkific. 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. ### Stack (fixed, do not substitute) - Video: A hosting provider with signed playback URLs · self-hosting video for a course is a bandwidth and encoding project - Entitlement: What was purchased, recorded as data with its own lifetime · access is the product and it has to survive everything else - Progress: Per lesson, tied to the student rather than the enrolment · a student who re-enrols should not lose two months of progress - Payments: Hosted checkout with subscriptions and one-off purchases · both models coexist and behave differently on failure - Runtime: TypeScript with PostgreSQL · enrolments, progress and payments are relational ### Data model (create this before Phase 1) - 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. ### Phase 1 · Video that plays and cannot be trivially taken Build: Upload video to a hosting provider with encoding, and serve playback through short-lived signed URLs bound to the student. Handle upload failures and long encodes. Accept that determined copying is not preventable. Trap: 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. Done when: Playback works through short-lived signed URLs bound to the student. A shared URL stops working quickly, tested by copying one. A long encode reports progress rather than appearing broken. An upload interrupted midway resumes rather than restarting. Do not build yet: No enrolment, payment or progress. ### Phase 2 · Entitlement as the product Build: Model entitlements separately from payments, with their own kind, start and expiry. Grant an entitlement on successful payment. Support manual grants and revocations with a recorded reason. Trap: 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. Done when: Access is decided by entitlement rather than payment state, verified by refunding a payment and confirming lifetime access persists per the stated policy. Manual grants and revocations record a reason. An expired entitlement blocks access without deleting progress. Two overlapping entitlements resolve to the more generous. Do not build yet: No payments, drip or progress yet. ### Phase 3 · Payments, including when they fail Build: Hosted checkout for one-off purchases and subscriptions. Grant entitlement on webhook confirmation. Handle a failed renewal with a grace period rather than immediate revocation, and handle a chargeback distinctly from a refund. Trap: 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. Done when: A one-off purchase grants a lifetime entitlement on webhook confirmation, not on redirect. A failed renewal enters a grace period with the student notified rather than losing access immediately. A chargeback is handled distinctly from a voluntary refund. A duplicate webhook does not grant two entitlements. Do not build yet: No progress tracking or content updates yet. ### Phase 4 · Progress that survives the course changing Build: Key progress on a stable lesson key rather than a version-specific id. Publish course versions without disturbing enrolled students. Handle a lesson being removed or split in a new version. Trap: 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. Done when: Republishing a course does not reset student progress, tested by editing and republishing with enrolled students. A removed lesson's progress is retained rather than deleted. A split lesson's progress follows a stated rule. Percentage complete recomputes correctly against the new structure. Do not build yet: No drip, certificates or reporting yet. ### Phase 5 · Drip, completion and closing down gracefully Build: Drip lessons by days since enrolment, computed in the student's zone. Record completion and issue a certificate. Support retiring a course while preserving access for people who already bought it. Trap: Retiring a course by unpublishing it removes access for everyone who bought lifetime access, which is exactly the promise you sold them. Separate stopping sales from removing access, and make retirement default to preserving existing entitlements. Done when: Drip release computes in the student's zone and does not shift across a daylight transition. Retiring a course stops new sales while preserving access for existing entitlements, tested directly. Completion is recorded and a certificate is issued and verifiable. Export contains courses, students, entitlements and progress. Do not build yet: Nothing after this. ### Out of scope (and why) - Digital rights management, which costs a great deal, degrades legitimate playback and does not stop determined copying. - Live cohort features such as scheduled sessions and community, which is a different product layered on top. - Marketing funnels, affiliates and upsells, which is most of what the hosted platforms actually sell. - Tax handling for digital goods across jurisdictions, which is specialist work that changes by territory. ### README must contain - That access is decided by entitlement rather than by payment state, and why a refund does not automatically remove it - That a failed renewal enters a grace period rather than revoking immediately - That progress is keyed on a stable lesson key so republishing does not reset students - That retiring a course stops sales without removing access for people who bought it - That no rights management is implemented, deliberately ===== ARCHITECTURE.md ===== # Architecture · Thinkific ## Stack | Part | Choice | Why | | --- | --- | --- | | Video | A hosting provider with signed playback URLs | self-hosting video for a course is a bandwidth and encoding project | | Entitlement | What was purchased, recorded as data with its own lifetime | access is the product and it has to survive everything else | | Progress | Per lesson, tied to the student rather than the enrolment | a student who re-enrols should not lose two months of progress | | Payments | Hosted checkout with subscriptions and one-off purchases | both models coexist and behave differently on failure | | Runtime | TypeScript with PostgreSQL | enrolments, progress and payments are relational | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Video | Hosted encoding and signed playback. | Any provider with per-viewer signed URLs. Rights management is not part of it. | | Entitlement | Access as its own record with a lifetime. | Not swappable for deriving access from payment state. | | Payments | Checkout, renewals and failure handling. | Any provider, provided grants happen on webhook and failures grace. | | Progress | Stable-key tracking across versions. | Any store, provided keys survive republishing. | | Lifecycle | Drip, completion and retirement. | Any implementation, provided retirement preserves existing access. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Students, entitlements and progress. - `VIDEO_PROVIDER_KEY` · required, secret · Uploads and signs playback URLs. - `PLAYBACK_URL_TTL_SECONDS` · required · Short enough that a shared link is useless, long enough not to interrupt playback. - `RENEWAL_GRACE_DAYS` · required · An expired card is not a cancellation. - `RETIRE_PRESERVES_ACCESS` · required · Present as a constant. Lifetime access means after you stop selling too. - `STRIPE_WEBHOOK_SECRET` · required, secret · Entitlements are granted from webhooks, so an unverified endpoint grants access. ## 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 · Thinkific product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: A hosting provider with signed playback URLs, What was purchased, recorded as data with its own lifetime, Per lesson, tied to the student rather than the enrolment, Hosted checkout with subscriptions and one-off purchases, TypeScript with PostgreSQL. - 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 - 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. - 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. - 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. - 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. - Retiring a course by unpublishing it removes access for everyone who bought lifetime access, which is exactly the promise you sold them. Separate stopping sales from removing access, and make retirement default to preserving existing entitlements. ===== MILESTONES.md ===== # Delivery milestones · Thinkific Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Video that plays and cannot be trivially taken Lessons stream reliably and a playback link is not a permanent copy. ### Steps 1. Use a hosting provider with encoding Self-hosting video for a course is a project of its own. Files: `src/video.ts` 2. Sign playback URLs short and per student It stops casual sharing, which is what actually happens. Files: `src/playback.ts` 3. Do not build rights management It costs a lot, hurts legitimate students, and stops nobody determined. Files: `docs/piracy-position.md` ### Done when - [ ] Playback works through signed URLs - [ ] A shared URL expires quickly - [ ] Long encodes report progress - [ ] Interrupted uploads resume ### 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. ## M2 · Entitlement as the product What a student may access is a record, not a side effect of a payment row. ### Steps 1. Separate entitlement from payment Access is the product. A payment is how it was acquired. Files: `src/entitlements.ts` 2. Support manual grant and revoke with reasons Support cases and refunds both need it, and both should be recorded. Files: `src/entitlements.ts` 3. Resolve overlaps generously A student with two reasons to have access should have access. Files: `src/access.ts`, `src/access.test.ts` ### Done when - [ ] Access derives from entitlement, not payment state - [ ] Manual grants and revocations record reasons - [ ] Expiry blocks access without deleting progress - [ ] Overlapping entitlements resolve generously ### 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. ## M3 · Payments, including when they fail Purchases and subscriptions work, and a failure does not lose a student. ### Steps 1. Grant on webhook, not redirect A student who pays and closes the tab has still paid. Files: `src/webhooks.ts` 2. Grace failed renewals An expired card is not a cancellation. Files: `src/renewals.ts` 3. Distinguish chargeback from refund Different causes, different responses, different records. Files: `src/payments.ts` ### Done when - [ ] Purchase grants on webhook confirmation - [ ] A failed renewal graces and notifies - [ ] Chargebacks are handled distinctly - [ ] Duplicate webhooks grant one entitlement ### 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. ## M4 · Progress that survives the course changing Updating a course does not reset anyone's progress. ### Steps 1. Key progress on a stable lesson key Version-specific ids reset everyone on republish. Files: `src/progress.ts`, `tests/republish.test.ts` 2. Publish versions without disturbing students Enrolled students should see improvements, not a reset. Files: `src/versions.ts` 3. Decide the split and removal rules Both happen. Both need an answer before the first update. Files: `src/progress.ts` ### Done when - [ ] Republishing does not reset progress - [ ] Removed lessons retain their progress - [ ] Split lessons follow a stated rule - [ ] Completion percentage recomputes correctly ### 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. ## M5 · Drip, completion and closing down gracefully (production only) Content releases on schedule, completion is recorded, and students are not stranded. ### Steps 1. Drip in the student's zone Days since enrolment, not a server-side clock. Files: `src/drip.ts` 2. Separate stopping sales from removing access Lifetime access means lifetime, including after you stop selling. Files: `src/courses.ts` 3. Make certificates verifiable A certificate nobody can check is a picture. Files: `src/certificates.ts` ### Done when - [ ] Drip computes correctly in the student's zone - [ ] Retiring preserves existing access - [ ] Certificates are issued and verifiable - [ ] Export contains entitlements and progress ### Watch out - Retiring a course by unpublishing it removes access for everyone who bought lifetime access, which is exactly the promise you sold them. Separate stopping sales from removing access, and make retirement default to preserving existing entitlements. ===== OPERATIONS.md ===== # Operations · Thinkific ## Backup Entitlements and progress are what students paid for and what they would be most upset to lose. Video is at the provider. Back up the database and confirm entitlements restore with their expiry states intact. ## Restore Restore and confirm entitlements and their revocation states are correct, since restoring a revoked entitlement as live gives access back to someone who was refunded, and losing a live one locks out a paying student. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Failed renewal volume and playback error rate. A rising playback error rate is usually the signing configuration rather than the provider, and students rarely report it, they just stop watching. ## Incident checklist A paying student locked out is the incident that matters most, and the usual cause is access being derived from a payment row that changed state. The entitlement model exists precisely so that cannot happen. 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 - [ ] Access is confirmed to derive from entitlement rather than payment state - [ ] A failed renewal is confirmed to grace rather than revoke - [ ] Republishing is confirmed not to reset progress - [ ] Retiring is confirmed to preserve existing access - [ ] A shared playback URL is confirmed to expire quickly ## Launch constraint Do not market omitted Thinkific 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 · secret. Students, entitlements and progress. DATABASE_URL=postgres://localhost:5432/courses # Required · secret. Uploads and signs playback URLs. VIDEO_PROVIDER_KEY=a provider key # Required. Short enough that a shared link is useless, long enough not to interrupt playback. PLAYBACK_URL_TTL_SECONDS=300 # Required. An expired card is not a cancellation. RENEWAL_GRACE_DAYS=7 # Required. Present as a constant. Lifetime access means after you stop selling too. RETIRE_PRESERVES_ACCESS=always # Required · secret. Entitlements are granted from webhooks, so an unverified endpoint grants access. STRIPE_WEBHOOK_SECRET=whsec_...
# Thinkific · indie build 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-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 | | --- | --- | --- | | Video | A hosting provider with signed playback URLs | self-hosting video for a course is a bandwidth and encoding project | | Entitlement | What was purchased, recorded as data with its own lifetime | access is the product and it has to survive everything else | | Progress | Per lesson, tied to the student rather than the enrolment | a student who re-enrols should not lose two months of progress | | Payments | Hosted checkout with subscriptions and one-off purchases | both models coexist and behave differently on failure | | Runtime | TypeScript with PostgreSQL | enrolments, progress and payments are relational | ## 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 server and the webhook handlers. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: Entitlements, progress and payments with real constraints. - Get it: Docker or your package manager. - Verify: psql connects and creates a table - [ ] **A video hosting provider with signed playback** · 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 - [ ] **A payment provider in test mode** · 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. - Verify: A purchase, a subscription renewal and a failed renewal all produce the right entitlement state - [ ] **Your access policy, written before selling** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Digital rights management. - Live cohorts and community features. - Marketing funnels, affiliates and upsells. - Cross-jurisdiction tax handling. - course builder, payments, communities, apps, integrations, and support - marketplace discovery - tax remittance - fraud operations - advanced course and community tooling If one of those is essential to you, that is the reason to keep paying for Thinkific, and the README should say so rather than pretend.
# Build brief · Thinkific 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 course platform where students keep access to what they paid for to replace Thinkific. 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. ### Stack (fixed, do not substitute) - Video: A hosting provider with signed playback URLs · self-hosting video for a course is a bandwidth and encoding project - Entitlement: What was purchased, recorded as data with its own lifetime · access is the product and it has to survive everything else - Progress: Per lesson, tied to the student rather than the enrolment · a student who re-enrols should not lose two months of progress - Payments: Hosted checkout with subscriptions and one-off purchases · both models coexist and behave differently on failure - Runtime: TypeScript with PostgreSQL · enrolments, progress and payments are relational ### Data model (create this before Phase 1) - 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. ### Phase 1 · Video that plays and cannot be trivially taken Build: Upload video to a hosting provider with encoding, and serve playback through short-lived signed URLs bound to the student. Handle upload failures and long encodes. Accept that determined copying is not preventable. Trap: 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. Done when: Playback works through short-lived signed URLs bound to the student. A shared URL stops working quickly, tested by copying one. A long encode reports progress rather than appearing broken. An upload interrupted midway resumes rather than restarting. Do not build yet: No enrolment, payment or progress. ### Phase 2 · Entitlement as the product Build: Model entitlements separately from payments, with their own kind, start and expiry. Grant an entitlement on successful payment. Support manual grants and revocations with a recorded reason. Trap: 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. Done when: Access is decided by entitlement rather than payment state, verified by refunding a payment and confirming lifetime access persists per the stated policy. Manual grants and revocations record a reason. An expired entitlement blocks access without deleting progress. Two overlapping entitlements resolve to the more generous. Do not build yet: No payments, drip or progress yet. ### Phase 3 · Payments, including when they fail Build: Hosted checkout for one-off purchases and subscriptions. Grant entitlement on webhook confirmation. Handle a failed renewal with a grace period rather than immediate revocation, and handle a chargeback distinctly from a refund. Trap: 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. Done when: A one-off purchase grants a lifetime entitlement on webhook confirmation, not on redirect. A failed renewal enters a grace period with the student notified rather than losing access immediately. A chargeback is handled distinctly from a voluntary refund. A duplicate webhook does not grant two entitlements. Do not build yet: No progress tracking or content updates yet. ### Phase 4 · Progress that survives the course changing Build: Key progress on a stable lesson key rather than a version-specific id. Publish course versions without disturbing enrolled students. Handle a lesson being removed or split in a new version. Trap: 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. Done when: Republishing a course does not reset student progress, tested by editing and republishing with enrolled students. A removed lesson's progress is retained rather than deleted. A split lesson's progress follows a stated rule. Percentage complete recomputes correctly against the new structure. Do not build yet: No drip, certificates or reporting yet. ### Phase 5 · Drip, completion and closing down gracefully Build: Drip lessons by days since enrolment, computed in the student's zone. Record completion and issue a certificate. Support retiring a course while preserving access for people who already bought it. Trap: Retiring a course by unpublishing it removes access for everyone who bought lifetime access, which is exactly the promise you sold them. Separate stopping sales from removing access, and make retirement default to preserving existing entitlements. Done when: Drip release computes in the student's zone and does not shift across a daylight transition. Retiring a course stops new sales while preserving access for existing entitlements, tested directly. Completion is recorded and a certificate is issued and verifiable. Export contains courses, students, entitlements and progress. Do not build yet: Nothing after this. ### Out of scope (and why) - Digital rights management, which costs a great deal, degrades legitimate playback and does not stop determined copying. - Live cohort features such as scheduled sessions and community, which is a different product layered on top. - Marketing funnels, affiliates and upsells, which is most of what the hosted platforms actually sell. - Tax handling for digital goods across jurisdictions, which is specialist work that changes by territory. ### README must contain - That access is decided by entitlement rather than by payment state, and why a refund does not automatically remove it - That a failed renewal enters a grace period rather than revoking immediately - That progress is keyed on a stable lesson key so republishing does not reset students - That retiring a course stops sales without removing access for people who bought it - That no rights management is implemented, deliberately
# Agent instructions · Thinkific indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: A hosting provider with signed playback URLs, What was purchased, recorded as data with its own lifetime, Per lesson, tied to the student rather than the enrolment, Hosted checkout with subscriptions and one-off purchases, TypeScript with PostgreSQL. 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 - 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. - 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. - 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. - 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.
# Build plan · Thinkific 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Video that plays and cannot be trivially taken Lessons stream reliably and a playback link is not a permanent copy. ### Steps 1. Use a hosting provider with encoding Self-hosting video for a course is a project of its own. Files: `src/video.ts` 2. Sign playback URLs short and per student It stops casual sharing, which is what actually happens. Files: `src/playback.ts` 3. Do not build rights management It costs a lot, hurts legitimate students, and stops nobody determined. Files: `docs/piracy-position.md` ### Done when - [ ] Playback works through signed URLs - [ ] A shared URL expires quickly - [ ] Long encodes report progress - [ ] Interrupted uploads resume ### 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. ## Phase 2 · Entitlement as the product What a student may access is a record, not a side effect of a payment row. ### Steps 1. Separate entitlement from payment Access is the product. A payment is how it was acquired. Files: `src/entitlements.ts` 2. Support manual grant and revoke with reasons Support cases and refunds both need it, and both should be recorded. Files: `src/entitlements.ts` 3. Resolve overlaps generously A student with two reasons to have access should have access. Files: `src/access.ts`, `src/access.test.ts` ### Done when - [ ] Access derives from entitlement, not payment state - [ ] Manual grants and revocations record reasons - [ ] Expiry blocks access without deleting progress - [ ] Overlapping entitlements resolve generously ### 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. ## Phase 3 · Payments, including when they fail Purchases and subscriptions work, and a failure does not lose a student. ### Steps 1. Grant on webhook, not redirect A student who pays and closes the tab has still paid. Files: `src/webhooks.ts` 2. Grace failed renewals An expired card is not a cancellation. Files: `src/renewals.ts` 3. Distinguish chargeback from refund Different causes, different responses, different records. Files: `src/payments.ts` ### Done when - [ ] Purchase grants on webhook confirmation - [ ] A failed renewal graces and notifies - [ ] Chargebacks are handled distinctly - [ ] Duplicate webhooks grant one entitlement ### 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. ## Phase 4 · Progress that survives the course changing Updating a course does not reset anyone's progress. ### Steps 1. Key progress on a stable lesson key Version-specific ids reset everyone on republish. Files: `src/progress.ts`, `tests/republish.test.ts` 2. Publish versions without disturbing students Enrolled students should see improvements, not a reset. Files: `src/versions.ts` 3. Decide the split and removal rules Both happen. Both need an answer before the first update. Files: `src/progress.ts` ### Done when - [ ] Republishing does not reset progress - [ ] Removed lessons retain their progress - [ ] Split lessons follow a stated rule - [ ] Completion percentage recomputes correctly ### 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. ## Not in this build - Digital rights management. - Live cohorts and community features. - Marketing funnels, affiliates and upsells. - Cross-jurisdiction tax handling. ## After v1, if you want it - Quizzes with attempt limits, which is where completion becomes meaningful - Bundles and multi-course entitlements, once the single-course model is proven - A student-facing receipt and access history, which removes most support questions
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Students, entitlements and progress. DATABASE_URL=postgres://localhost:5432/courses # Required · secret. Uploads and signs playback URLs. VIDEO_PROVIDER_KEY=a provider key # Required. Short enough that a shared link is useless, long enough not to interrupt playback. PLAYBACK_URL_TTL_SECONDS=300 # Required. An expired card is not a cancellation. RENEWAL_GRACE_DAYS=7 # Required. Present as a constant. Lifetime access means after you stop selling too. RETIRE_PRESERVES_ACCESS=always # Required · secret. Entitlements are granted from webhooks, so an unverified endpoint grants access. STRIPE_WEBHOOK_SECRET=whsec_...
# Thinkific · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Thinkific, host a structured course with lessons, progress, and simple quizzes. The hard boundary is course builder, payments, communities, apps, integrations, and support, plus payments, tax, distribution, and operations. ## Product outcome A course platform where students keep what they bought through refunds, republishes and retirements, and where a card expiring does not cost you the student. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Stripe account - PostgreSQL - S3-compatible storage - public HTTPS deployment - email provider ## Explicit non-goals for v1 - Digital rights management. - Live cohorts and community features. - Marketing funnels, affiliates and upsells. - Cross-jurisdiction tax handling. - course builder, payments, communities, apps, integrations, and support - marketplace discovery - tax remittance - fraud operations - advanced course and community tooling ## Success criteria - Access is confirmed to derive from entitlement rather than payment state - A failed renewal is confirmed to grace rather than revoke - Republishing is confirmed not to reset progress - Retiring is confirmed to preserve existing access - A shared playback URL is confirmed to expire quickly
# Build brief · Thinkific 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 course platform where students keep access to what they paid for to replace Thinkific. 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. ### Stack (fixed, do not substitute) - Video: A hosting provider with signed playback URLs · self-hosting video for a course is a bandwidth and encoding project - Entitlement: What was purchased, recorded as data with its own lifetime · access is the product and it has to survive everything else - Progress: Per lesson, tied to the student rather than the enrolment · a student who re-enrols should not lose two months of progress - Payments: Hosted checkout with subscriptions and one-off purchases · both models coexist and behave differently on failure - Runtime: TypeScript with PostgreSQL · enrolments, progress and payments are relational ### Data model (create this before Phase 1) - 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. ### Phase 1 · Video that plays and cannot be trivially taken Build: Upload video to a hosting provider with encoding, and serve playback through short-lived signed URLs bound to the student. Handle upload failures and long encodes. Accept that determined copying is not preventable. Trap: 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. Done when: Playback works through short-lived signed URLs bound to the student. A shared URL stops working quickly, tested by copying one. A long encode reports progress rather than appearing broken. An upload interrupted midway resumes rather than restarting. Do not build yet: No enrolment, payment or progress. ### Phase 2 · Entitlement as the product Build: Model entitlements separately from payments, with their own kind, start and expiry. Grant an entitlement on successful payment. Support manual grants and revocations with a recorded reason. Trap: 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. Done when: Access is decided by entitlement rather than payment state, verified by refunding a payment and confirming lifetime access persists per the stated policy. Manual grants and revocations record a reason. An expired entitlement blocks access without deleting progress. Two overlapping entitlements resolve to the more generous. Do not build yet: No payments, drip or progress yet. ### Phase 3 · Payments, including when they fail Build: Hosted checkout for one-off purchases and subscriptions. Grant entitlement on webhook confirmation. Handle a failed renewal with a grace period rather than immediate revocation, and handle a chargeback distinctly from a refund. Trap: 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. Done when: A one-off purchase grants a lifetime entitlement on webhook confirmation, not on redirect. A failed renewal enters a grace period with the student notified rather than losing access immediately. A chargeback is handled distinctly from a voluntary refund. A duplicate webhook does not grant two entitlements. Do not build yet: No progress tracking or content updates yet. ### Phase 4 · Progress that survives the course changing Build: Key progress on a stable lesson key rather than a version-specific id. Publish course versions without disturbing enrolled students. Handle a lesson being removed or split in a new version. Trap: 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. Done when: Republishing a course does not reset student progress, tested by editing and republishing with enrolled students. A removed lesson's progress is retained rather than deleted. A split lesson's progress follows a stated rule. Percentage complete recomputes correctly against the new structure. Do not build yet: No drip, certificates or reporting yet. ### Phase 5 · Drip, completion and closing down gracefully Build: Drip lessons by days since enrolment, computed in the student's zone. Record completion and issue a certificate. Support retiring a course while preserving access for people who already bought it. Trap: Retiring a course by unpublishing it removes access for everyone who bought lifetime access, which is exactly the promise you sold them. Separate stopping sales from removing access, and make retirement default to preserving existing entitlements. Done when: Drip release computes in the student's zone and does not shift across a daylight transition. Retiring a course stops new sales while preserving access for existing entitlements, tested directly. Completion is recorded and a certificate is issued and verifiable. Export contains courses, students, entitlements and progress. Do not build yet: Nothing after this. ### Out of scope (and why) - Digital rights management, which costs a great deal, degrades legitimate playback and does not stop determined copying. - Live cohort features such as scheduled sessions and community, which is a different product layered on top. - Marketing funnels, affiliates and upsells, which is most of what the hosted platforms actually sell. - Tax handling for digital goods across jurisdictions, which is specialist work that changes by territory. ### README must contain - That access is decided by entitlement rather than by payment state, and why a refund does not automatically remove it - That a failed renewal enters a grace period rather than revoking immediately - That progress is keyed on a stable lesson key so republishing does not reset students - That retiring a course stops sales without removing access for people who bought it - That no rights management is implemented, deliberately
# Architecture · Thinkific ## Stack | Part | Choice | Why | | --- | --- | --- | | Video | A hosting provider with signed playback URLs | self-hosting video for a course is a bandwidth and encoding project | | Entitlement | What was purchased, recorded as data with its own lifetime | access is the product and it has to survive everything else | | Progress | Per lesson, tied to the student rather than the enrolment | a student who re-enrols should not lose two months of progress | | Payments | Hosted checkout with subscriptions and one-off purchases | both models coexist and behave differently on failure | | Runtime | TypeScript with PostgreSQL | enrolments, progress and payments are relational | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Video | Hosted encoding and signed playback. | Any provider with per-viewer signed URLs. Rights management is not part of it. | | Entitlement | Access as its own record with a lifetime. | Not swappable for deriving access from payment state. | | Payments | Checkout, renewals and failure handling. | Any provider, provided grants happen on webhook and failures grace. | | Progress | Stable-key tracking across versions. | Any store, provided keys survive republishing. | | Lifecycle | Drip, completion and retirement. | Any implementation, provided retirement preserves existing access. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Students, entitlements and progress. - `VIDEO_PROVIDER_KEY` · required, secret · Uploads and signs playback URLs. - `PLAYBACK_URL_TTL_SECONDS` · required · Short enough that a shared link is useless, long enough not to interrupt playback. - `RENEWAL_GRACE_DAYS` · required · An expired card is not a cancellation. - `RETIRE_PRESERVES_ACCESS` · required · Present as a constant. Lifetime access means after you stop selling too. - `STRIPE_WEBHOOK_SECRET` · required, secret · Entitlements are granted from webhooks, so an unverified endpoint grants access. ## 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 · Thinkific product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: A hosting provider with signed playback URLs, What was purchased, recorded as data with its own lifetime, Per lesson, tied to the student rather than the enrolment, Hosted checkout with subscriptions and one-off purchases, TypeScript with PostgreSQL. - 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 - 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. - 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. - 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. - 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. - Retiring a course by unpublishing it removes access for everyone who bought lifetime access, which is exactly the promise you sold them. Separate stopping sales from removing access, and make retirement default to preserving existing entitlements.
# Delivery milestones · Thinkific Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Video that plays and cannot be trivially taken Lessons stream reliably and a playback link is not a permanent copy. ### Steps 1. Use a hosting provider with encoding Self-hosting video for a course is a project of its own. Files: `src/video.ts` 2. Sign playback URLs short and per student It stops casual sharing, which is what actually happens. Files: `src/playback.ts` 3. Do not build rights management It costs a lot, hurts legitimate students, and stops nobody determined. Files: `docs/piracy-position.md` ### Done when - [ ] Playback works through signed URLs - [ ] A shared URL expires quickly - [ ] Long encodes report progress - [ ] Interrupted uploads resume ### 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. ## M2 · Entitlement as the product What a student may access is a record, not a side effect of a payment row. ### Steps 1. Separate entitlement from payment Access is the product. A payment is how it was acquired. Files: `src/entitlements.ts` 2. Support manual grant and revoke with reasons Support cases and refunds both need it, and both should be recorded. Files: `src/entitlements.ts` 3. Resolve overlaps generously A student with two reasons to have access should have access. Files: `src/access.ts`, `src/access.test.ts` ### Done when - [ ] Access derives from entitlement, not payment state - [ ] Manual grants and revocations record reasons - [ ] Expiry blocks access without deleting progress - [ ] Overlapping entitlements resolve generously ### 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. ## M3 · Payments, including when they fail Purchases and subscriptions work, and a failure does not lose a student. ### Steps 1. Grant on webhook, not redirect A student who pays and closes the tab has still paid. Files: `src/webhooks.ts` 2. Grace failed renewals An expired card is not a cancellation. Files: `src/renewals.ts` 3. Distinguish chargeback from refund Different causes, different responses, different records. Files: `src/payments.ts` ### Done when - [ ] Purchase grants on webhook confirmation - [ ] A failed renewal graces and notifies - [ ] Chargebacks are handled distinctly - [ ] Duplicate webhooks grant one entitlement ### 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. ## M4 · Progress that survives the course changing Updating a course does not reset anyone's progress. ### Steps 1. Key progress on a stable lesson key Version-specific ids reset everyone on republish. Files: `src/progress.ts`, `tests/republish.test.ts` 2. Publish versions without disturbing students Enrolled students should see improvements, not a reset. Files: `src/versions.ts` 3. Decide the split and removal rules Both happen. Both need an answer before the first update. Files: `src/progress.ts` ### Done when - [ ] Republishing does not reset progress - [ ] Removed lessons retain their progress - [ ] Split lessons follow a stated rule - [ ] Completion percentage recomputes correctly ### 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. ## M5 · Drip, completion and closing down gracefully (production only) Content releases on schedule, completion is recorded, and students are not stranded. ### Steps 1. Drip in the student's zone Days since enrolment, not a server-side clock. Files: `src/drip.ts` 2. Separate stopping sales from removing access Lifetime access means lifetime, including after you stop selling. Files: `src/courses.ts` 3. Make certificates verifiable A certificate nobody can check is a picture. Files: `src/certificates.ts` ### Done when - [ ] Drip computes correctly in the student's zone - [ ] Retiring preserves existing access - [ ] Certificates are issued and verifiable - [ ] Export contains entitlements and progress ### Watch out - Retiring a course by unpublishing it removes access for everyone who bought lifetime access, which is exactly the promise you sold them. Separate stopping sales from removing access, and make retirement default to preserving existing entitlements.
# Operations · Thinkific ## Backup Entitlements and progress are what students paid for and what they would be most upset to lose. Video is at the provider. Back up the database and confirm entitlements restore with their expiry states intact. ## Restore Restore and confirm entitlements and their revocation states are correct, since restoring a revoked entitlement as live gives access back to someone who was refunded, and losing a live one locks out a paying student. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Failed renewal volume and playback error rate. A rising playback error rate is usually the signing configuration rather than the provider, and students rarely report it, they just stop watching. ## Incident checklist A paying student locked out is the incident that matters most, and the usual cause is access being derived from a payment row that changed state. The entitlement model exists precisely so that cannot happen. 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 - [ ] Access is confirmed to derive from entitlement rather than payment state - [ ] A failed renewal is confirmed to grace rather than revoke - [ ] Republishing is confirmed not to reset progress - [ ] Retiring is confirmed to preserve existing access - [ ] A shared playback URL is confirmed to expire quickly ## Launch constraint Do not market omitted Thinkific 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 · secret. Students, entitlements and progress. DATABASE_URL=postgres://localhost:5432/courses # Required · secret. Uploads and signs playback URLs. VIDEO_PROVIDER_KEY=a provider key # Required. Short enough that a shared link is useless, long enough not to interrupt playback. PLAYBACK_URL_TTL_SECONDS=300 # Required. An expired card is not a cancellation. RENEWAL_GRACE_DAYS=7 # Required. Present as a constant. Lifetime access means after you stop selling too. RETIRE_PRESERVES_ACCESS=always # Required · secret. Entitlements are granted from webhooks, so an unverified endpoint grants access. STRIPE_WEBHOOK_SECRET=whsec_...
$ choose a build depth, inspect the files, then open the complete pack in your agent
People still pay for Thinkific because creators pay to outsource the dangerous and tedious parts around money, delivery, tax, and customer access. The recurring cost buys checkout, webhooks, tax, refunds, fraud, entitlements, storage, email, accessibility, backups, and customer support, not just the visible interface.
xcourse builder, payments, communities, apps, integrations, and support
xmarketplace discovery
xtax remittance
xfraud operations
xadvanced course and community tooling
Don't feel like building it? These folks already made it free.
all 6 free alternatives to Thinkific →· no votes, no pay-to-list · just what's real
Thinkific pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| basic | $54/workspace | $40/workspace | Unlimited courses; 10,000 current students; 100 GB video uploads/month; 1 community with 5 spaces; 5 digital downloads; 5 coaching/webinar products; 1 admin |
| start | $109/workspace | $82/workspace | Unlimited courses; 10,000 current students; 200 GB video uploads/month; 1 community with 10 spaces; unlimited downloads/coaching products; 2 admins |
| grow | $219/workspace | $164/workspace | Unlimited courses; 10,000 current students; 400 GB video uploads/month; 3 communities with 20 spaces each; 6 admins |
| plus | custom | — | Custom current-student allowance; unlimited courses, communities, and spaces; video allowance starts at 7,500 GB/month |
free tierno free tier; 30-day trial only
billingmonthly + annual (annual prices shown as effective monthly rates; about 25% lower)
hidden costsBranded mobile app is $199/month. Thinkific Payments starts at 2.9%+$0.30, plus 1.5% for international cards, 1% FX, 0.7% for subscriptions/payment plans, 0.5% tax/VAT service, and 0.4% invoicing up to $5; instant payout is 1.5%. Third-party gateway fees are 5% Basic, 2% Start, 1% Grow, and 0% on the first $1M/year on Plus.
verified 2026-08-10 · source ↗
Vibecode Thinkific
Kinda. The core of Thinkific is buildable in a weekend with the prompt on this page, but there are real gaps: course builder, payments, communities, apps, integrations, and support, marketplace discovery. Read the honest list above before committing.
How much does Thinkific cost?
Thinkific costs about $54/month (Basic, checked 2026-08-10), which is $648 per year.
What do I lose by replacing Thinkific?
Honestly: course builder, payments, communities, apps, integrations, and support; marketplace discovery; tax remittance; fraud operations; advanced course and community tooling. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Thinkific?
Yes: Odoo Community (Paid courses, lessons, quizzes and progress inside a giant business suite; overkill arrives preinstalled.) Moodle (Lessons, progress and quizzes with decades of scars and capabilities.) Frappe Learning (Structured courses, lessons, progress, quizzes and certificates without a licence bill; Docker remains.) All 6 curated free alternatives are at vibecodeit.com/thinkific/alternatives. The prompt is for when you want it exactly your way.