Vibecode Kajabi
track this build5 phases, 15 steps, beginner friendly0%You can build one course checkout or landing funnel, but Kajabi bundles site, course, community, email, automations, payments, analytics, and support.
You are building a lean indie version of Kajabi. 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 ===== # Kajabi · indie build A site where lessons live behind a purchase: Stripe checkout for one-off and subscription access, content gated per product, video that cannot simply be downloaded from the page source, progress tracking, and an email sequence that fires from purchase events rather than from a marketing tool you also have to run. 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 | | --- | --- | --- | | Framework | Django 5 with Python 3.12 | authentication, an admin for your content, migrations and a security posture on day one | | Interface | Django templates with HTMX | course pages are documents, and a frontend framework buys nothing here | | Database | PostgreSQL | purchases and access grants are money-adjacent and want a real database | | Payments | Stripe Checkout with webhooks | hosted checkout keeps card handling out of your scope entirely | | Video | An object store with signed, expiring URLs | the difference between gated video and video anyone can copy the link to | | Email | A transactional provider driven by an outbox table | sequences fire from purchase events, and a failed send must be visible rather than lost | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Python 3.12 or newer** · free - Why: Runs Django. - Get it: python.org or your package manager, then a virtual environment. - Verify: python3 --version prints 3.12 or higher - [ ] **PostgreSQL 16 or newer** · free - Why: Holds purchases and entitlements, which are money-adjacent and deserve a real database. - Get it: Install locally or run it in Docker, then create a database for the project. - Verify: psql connects and reports 16 or higher - [ ] **A Stripe account with test mode keys** · free in test mode; live payments cost per transaction plus an additional fee on subscriptions in most regions - Why: Checkout, subscriptions, refunds and disputes all come from here, and test mode covers every one of them. - Get it: Sign up at stripe.com, take the secret key from Developers then API keys, and the webhook signing secret from your endpoint or from the listen command. - Verify: A triggered test event reaches your endpoint and is verified - [ ] **An S3-compatible object store** · storage is cheap; egress is the real cost and scales with how much students watch - Why: Video must not be publicly readable, and signed URLs are how a paying student watches without the link being shareable. - Get it: Create a private bucket with any provider, and credentials scoped to it. Confirm the provider supports presigned URLs with a short expiry. - Verify: An unsigned request to an object is refused and a signed one succeeds - [ ] **A transactional email provider with a verified domain** · free tiers usually cover a few thousand messages a month - Why: Password reset, receipts and sequences all need delivery. Domain verification is the slow step. - Get it: Create an account, add the DNS records they specify, and take the credentials. Start this before Phase 1 because propagation takes hours. - Verify: A password reset email reaches an inbox and is not in spam - [ ] **One real course with several lessons** · free - Why: Placeholder content hides ordering, navigation and video length problems that appear immediately with real material. - Get it: Use a course you have already written, or write three real lessons. Include at least one video long enough to cross a signed URL expiry. - Verify: The course is authored and one lesson's video exceeds the URL expiry ## Quick start ```sh pip install django psycopg[binary] python-dotenv django-admin startproject config . ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - The all-in-one convenience and the single bill. - The funnel and landing page builder. - Marketing automation with branching and scoring. - Mobile applications and offline lesson downloads. - Affiliate tracking and platform analytics. - all-in-one stack - funnels - email automations - communities - course delivery - analytics - customer support If one of those is essential to you, that is the reason to keep paying for Kajabi, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Kajabi 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 and membership site with payments, gated content and the email sequence that follows a purchase to replace Kajabi. 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) - Framework: Django 5 with Python 3.12 · authentication, an admin for your content, migrations and a security posture on day one - Interface: Django templates with HTMX · course pages are documents, and a frontend framework buys nothing here - Database: PostgreSQL · purchases and access grants are money-adjacent and want a real database - Payments: Stripe Checkout with webhooks · hosted checkout keeps card handling out of your scope entirely - Video: An object store with signed, expiring URLs · the difference between gated video and video anyone can copy the link to - Email: A transactional provider driven by an outbox table · sequences fire from purchase events, and a failed send must be visible rather than lost ### Data model (create this before Phase 1) - products(id, name, slug, price_cents, kind, active) · kind is one_time or subscription - courses(id, product_id, title), modules(id, course_id, title, position), lessons(id, module_id, title, position, video_key, body) - purchases(id, user_id, product_id, stripe_session_id, stripe_subscription_id, state, purchased_at, access_until) - entitlements(user_id, product_id, granted_at, revoked_at, reason) · the only thing any gate consults - progress(user_id, lesson_id, completed_at, seconds_watched) - email_sequences(id, product_id, name), email_steps(sequence_id, position, delay_hours, subject, body), email_outbox(id, user_id, step_id, state, attempts, last_error) - Every gate checks entitlements and nothing else. Purchases write entitlements; they are never consulted directly by a page. ### Phase 1 · Accounts, content and the admin Build: A Django project with accounts, password reset over real email, and the content models registered in the admin. Author one real course with several lessons so every later phase has something to gate. Trap: Django's password reset silently does nothing useful with the console email backend, which looks like success in development. Configure a real transactional provider now, because everything in Phase 5 depends on it and domain verification takes time. Done when: An account can be created, logged out of and back into, and a password reset email arrives at a real inbox. A course with modules and lessons is authored through the admin and renders on the site. The admin is not at the default path and refuses anonymous access. Do not build yet: No payments, no gating, no email sequences. ### Phase 2 · Entitlements as the single gate Build: An entitlements table and one function that answers whether a user may access a product. Every view and template gate calls it. Grant entitlements manually for now, so gating is proven before payments complicate it. Trap: Scattering access checks across views and templates guarantees one of them is wrong, and the wrong one is the page that leaks. One function, called everywhere, with a test that enumerates every gated URL and asserts a refusal for a user without an entitlement. Done when: A user without an entitlement is refused every gated URL, verified by a test that enumerates them rather than by checking a few by hand. Granting an entitlement opens exactly the lessons of that product and no others. A revoked entitlement closes access immediately. Do not build yet: No payments yet, so entitlements are granted by hand. ### Phase 3 · Payments that grant access Build: Stripe Checkout for one-off and subscription products. A webhook endpoint that verifies its signature, records the event identifier for idempotency, and writes the entitlement. For subscriptions, entitlement follows the period rather than ending at the first failed payment. Trap: Granting on the success redirect is the classic mistake, and here it hands out course access to anyone who can replay a URL. The webhook is the only writer of entitlements, and the redirect is a thank-you page that may arrive before the entitlement exists, so it must handle that gracefully. Done when: A test-mode purchase grants access only after the webhook arrives. Replaying the same event leaves one entitlement. A cancelled subscription retains access until the period end and then loses it. A failed subscription payment does not revoke access while Stripe is still retrying. A bad signature is rejected with a 400. Do not build yet: No video protection or email sequences yet. ### Phase 4 · Video that is actually gated Build: Store video in an object store with no public access. Serve short-lived signed URLs generated only after the entitlement check. Set a short expiry and re-sign on demand rather than issuing one long-lived link. Trap: A signed URL with a long expiry is a shareable link to your course for as long as it lasts, and once it is in a group chat you cannot revoke it. Keep expiry short, minutes rather than days, and let the player request a fresh URL when it needs one. Done when: A video URL taken from the page and opened in a private window works within the expiry and fails after it. The object store returns a refusal for an unsigned request. A user without an entitlement receives no URL at all rather than a URL that fails later. Playback continues across the expiry boundary because the player re-signs. Do not build yet: No email sequences yet. ### Phase 5 · Email sequences from real events Build: Sequences of steps with delays, attached to a product. A purchase enqueues the sequence into an outbox. A scheduled command sends due messages, records failures with their error, and retries with backoff up to a cap. Unsubscribe must work. Trap: Sending during the webhook request couples your email provider's availability to Stripe's delivery timeout, and a slow provider turns into a failed webhook and a missing entitlement. Enqueue in the webhook and send from a scheduled job, always. Done when: A purchase enqueues the sequence with correct due times. Running the send command twice sends each message once. A permanently failing address is marked failed after the attempt cap rather than retried forever. An unsubscribe stops further sequence messages but not transactional ones such as a receipt. Do not build yet: Launch hardening is the last phase. ### Phase 6 · Launch, refunds and the restore drill Build: Deploy behind TLS with debug off. Handle refunds and disputes by revoking entitlements from the webhook. Back up the database and the video store off the machine, and restore both onto a clean server. Trap: A refund that leaves the entitlement in place means you have given the course away, and it is easy to miss because the refund path is rarely tested. Handle the refund and dispute events explicitly and test them with the Stripe command line tool before launch, not after the first one. Done when: A refund event revokes the entitlement and the student loses access. A dispute event does the same. The site serves over HTTPS with debug off. A restore onto a clean server reproduces courses, purchases and entitlements, video plays, and the date is recorded. Do not build yet: Nothing after this. ### Out of scope (and why) - The all-in-one convenience. You are now running a website, a payment integration, a video store and an email sender, and each has its own failure mode and its own bill. - The funnel builder and landing page editor, which is a visual tool and a large product in its own right. - Marketing automation with branching, scoring and segmentation. This build sends a linear sequence from a purchase event and stops there. - Mobile applications and offline lesson downloads, which students of paid courses increasingly expect. - The affiliate system and the analytics that come with a mature platform. ### README must contain - That entitlements are the single gate and that every access decision goes through one function - That the Stripe webhook is the only writer of entitlements, including for refunds and disputes - That video URLs are short-lived and signed only after the entitlement check - That sequence email is enqueued in the webhook and sent from a scheduled job - The backup schedule for database and video, and the date of the last restore ===== AGENTS.md ===== # Agent instructions · Kajabi indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Django 5 with Python 3.12, Django templates with HTMX, PostgreSQL, Stripe Checkout with webhooks, An object store with signed, expiring URLs, A transactional provider driven by an outbox table. 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 - Django's password reset silently does nothing useful with the console email backend, which looks like success in development. Configure a real transactional provider now, because everything in Phase 5 depends on it and domain verification takes time. - Scattering access checks across views and templates guarantees one of them is wrong, and the wrong one is the page that leaks. One function, called everywhere, with a test that enumerates every gated URL and asserts a refusal for a user without an entitlement. - Granting on the success redirect is the classic mistake, and here it hands out course access to anyone who can replay a URL. The webhook is the only writer of entitlements, and the redirect is a thank-you page that may arrive before the entitlement exists, so it must handle that gracefully. - A signed URL with a long expiry is a shareable link to your course for as long as it lasts, and once it is in a group chat you cannot revoke it. Keep expiry short, minutes rather than days, and let the player request a fresh URL when it needs one. - Sending during the webhook request couples your email provider's availability to Stripe's delivery timeout, and a slow provider turns into a failed webhook and a missing entitlement. Enqueue in the webhook and send from a scheduled job, always. ===== BUILD_PLAN.md ===== # Build plan · Kajabi A site where lessons live behind a purchase: Stripe checkout for one-off and subscription access, content gated per product, video that cannot simply be downloaded from the page source, progress tracking, and an email sequence that fires from purchase events rather than from a marketing tool you also have to run. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Accounts, content and the admin You can author a course and a student can create an account, before any money moves. ### Steps 1. Create the project with PostgreSQL and real email Settings from environment variables, a transactional provider configured, and a reset email sent to yourself to prove it. Files: `config/settings.py` ```sh pip install django psycopg[binary] python-dotenv django-admin startproject config . ``` 2. Model courses, modules and lessons Ordered with explicit position fields rather than relying on insertion order. Files: `app/models.py` ```sh python manage.py makemigrations && python manage.py migrate ``` 3. Author one real course Not lorem ipsum. Real lessons expose ordering, length and navigation problems that placeholder content hides. ### Done when - [ ] Signup, logout and login all work - [ ] A password reset email reaches a real inbox - [ ] A course renders with modules and lessons in the right order - [ ] The admin refuses anonymous access and is not at the default path ### Watch out - Django's password reset silently does nothing useful with the console email backend, which looks like success in development. Configure a real transactional provider now, because everything in Phase 5 depends on it and domain verification takes time. ## Phase 2 · Entitlements as the single gate Every access decision consults one table, so there is one place to be right. ### Steps 1. Write the single access function User plus product to a boolean, with the reason. Nothing else decides access anywhere in the codebase. Files: `app/access.py` 2. Gate every view through it Views, not templates. A hidden link is not a gate. Files: `app/views.py` 3. Enumerate gated URLs in a test Walk the URL configuration, request every gated route as an entitled and unentitled user, assert both outcomes. Files: `app/tests/test_gates.py` ### Done when - [ ] Every gated URL is refused without an entitlement, proved by an enumerating test - [ ] An entitlement opens exactly its own product's lessons - [ ] Revoking closes access immediately - [ ] No template performs an access decision ### Watch out - Scattering access checks across views and templates guarantees one of them is wrong, and the wrong one is the page that leaks. One function, called everywhere, with a test that enumerates every gated URL and asserts a refusal for a user without an entitlement. ## Phase 3 · Payments that grant access Paying grants an entitlement, and only the webhook believes a payment happened. ### Steps 1. Create Checkout sessions server side Prices from your database, never from the client. Attach the user and product as metadata. Files: `app/payments.py` ```sh pip install stripe ``` 2. Make the webhook the only writer Signature verification, a processed-events table, and entitlement writes. Exempt from CSRF because the signature is the authentication. Files: `app/webhooks.py` 3. Handle the subscription lifecycle Period end for cancellation, terminal state for failure. A scheduled job revokes at period end. Files: `app/subscriptions.py` ### Done when - [ ] Access is granted only after the webhook - [ ] A replayed event leaves one entitlement - [ ] A cancelled subscription keeps access until the period end - [ ] A retrying failed payment does not revoke access ### Watch out - Granting on the success redirect is the classic mistake, and here it hands out course access to anyone who can replay a URL. The webhook is the only writer of entitlements, and the redirect is a thank-you page that may arrive before the entitlement exists, so it must handle that gracefully. ## Phase 4 · Video that is actually gated A paying student can watch, and a non-paying visitor cannot simply copy the link. ### Steps 1. Lock the bucket down No public read. Test with an unsigned request and confirm a refusal before relying on signing. 2. Sign after checking entitlement The entitlement check happens before a URL exists. An unentitled user gets nothing to try. Files: `app/video.py` 3. Re-sign during playback The player requests a fresh URL before expiry so a long lesson does not stop mid-sentence. Files: `static/player.js` ### Done when - [ ] An unsigned request to the object store is refused - [ ] A copied URL stops working after the expiry - [ ] An unentitled user receives no URL at all - [ ] Playback continues across an expiry boundary ### Watch out - A signed URL with a long expiry is a shareable link to your course for as long as it lasts, and once it is in a group chat you cannot revoke it. Keep expiry short, minutes rather than days, and let the player request a fresh URL when it needs one. ## Phase 5 · Email sequences from real events Buying triggers a sequence, and a failed send is a row you can see rather than a message that vanished. ### Steps 1. Model sequences and steps Delays relative to the trigger event, so a three day step means three days after purchase rather than after the previous send. Files: `app/sequences.py` 2. Enqueue in the webhook, send in a job The webhook writes rows and returns. A scheduled command does the sending. Files: `app/management/commands/send_outbox.py` ```sh python manage.py send_outbox ``` 3. Implement unsubscribe properly A signed link, honoured for marketing sequences and not for receipts. Test both cases. Files: `app/unsubscribe.py` ### Done when - [ ] A purchase enqueues the sequence with correct due times - [ ] Running the send command twice sends each message once - [ ] A failing address is capped rather than retried forever - [ ] Unsubscribe stops sequence email and not receipts ### Watch out - Sending during the webhook request couples your email provider's availability to Stripe's delivery timeout, and a slow provider turns into a failed webhook and a missing entitlement. Enqueue in the webhook and send from a scheduled job, always. ## Not in this build - The all-in-one convenience and the single bill. - The funnel and landing page builder. - Marketing automation with branching and scoring. - Mobile applications and offline lesson downloads. - Affiliate tracking and platform analytics. ## After v1, if you want it - Drip release of lessons on a schedule after purchase, which reuses the sequence timing - Certificates of completion generated from the progress table - A coupon system, which Stripe can carry rather than your own code ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Generated once. Changing it invalidates every session and signed value, including unsubscribe links. DJANGO_SECRET_KEY=replace-with-50-random-characters # Required. Zero everywhere but your own machine. DJANGO_DEBUG=0 # Required · secret. Your PostgreSQL connection string. DATABASE_URL=postgres://user:pass@localhost:5432/courses # Required · secret. Stripe dashboard. Stay in test mode until the refund path has been tested. STRIPE_SECRET_KEY=sk_test_... # Required · secret. From the registered endpoint or the listen command. STRIPE_WEBHOOK_SECRET=whsec_... # Required. The private bucket holding lesson video. VIDEO_BUCKET=courses-video # Required. How long a signed video URL lasts. Short, because a long one is a shareable link to your course. VIDEO_URL_EXPIRY_SECONDS=300 # Required · secret. Transactional provider credentials. EMAIL_URL=smtp://user:pass@smtp.provider.com:587
You are building a lean indie version of Kajabi. 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 ===== # Kajabi · indie build A site where lessons live behind a purchase: Stripe checkout for one-off and subscription access, content gated per product, video that cannot simply be downloaded from the page source, progress tracking, and an email sequence that fires from purchase events rather than from a marketing tool you also have to run. 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 | | --- | --- | --- | | Framework | Django 5 with Python 3.12 | authentication, an admin for your content, migrations and a security posture on day one | | Interface | Django templates with HTMX | course pages are documents, and a frontend framework buys nothing here | | Database | PostgreSQL | purchases and access grants are money-adjacent and want a real database | | Payments | Stripe Checkout with webhooks | hosted checkout keeps card handling out of your scope entirely | | Video | An object store with signed, expiring URLs | the difference between gated video and video anyone can copy the link to | | Email | A transactional provider driven by an outbox table | sequences fire from purchase events, and a failed send must be visible rather than lost | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Python 3.12 or newer** · free - Why: Runs Django. - Get it: python.org or your package manager, then a virtual environment. - Verify: python3 --version prints 3.12 or higher - [ ] **PostgreSQL 16 or newer** · free - Why: Holds purchases and entitlements, which are money-adjacent and deserve a real database. - Get it: Install locally or run it in Docker, then create a database for the project. - Verify: psql connects and reports 16 or higher - [ ] **A Stripe account with test mode keys** · free in test mode; live payments cost per transaction plus an additional fee on subscriptions in most regions - Why: Checkout, subscriptions, refunds and disputes all come from here, and test mode covers every one of them. - Get it: Sign up at stripe.com, take the secret key from Developers then API keys, and the webhook signing secret from your endpoint or from the listen command. - Verify: A triggered test event reaches your endpoint and is verified - [ ] **An S3-compatible object store** · storage is cheap; egress is the real cost and scales with how much students watch - Why: Video must not be publicly readable, and signed URLs are how a paying student watches without the link being shareable. - Get it: Create a private bucket with any provider, and credentials scoped to it. Confirm the provider supports presigned URLs with a short expiry. - Verify: An unsigned request to an object is refused and a signed one succeeds - [ ] **A transactional email provider with a verified domain** · free tiers usually cover a few thousand messages a month - Why: Password reset, receipts and sequences all need delivery. Domain verification is the slow step. - Get it: Create an account, add the DNS records they specify, and take the credentials. Start this before Phase 1 because propagation takes hours. - Verify: A password reset email reaches an inbox and is not in spam - [ ] **One real course with several lessons** · free - Why: Placeholder content hides ordering, navigation and video length problems that appear immediately with real material. - Get it: Use a course you have already written, or write three real lessons. Include at least one video long enough to cross a signed URL expiry. - Verify: The course is authored and one lesson's video exceeds the URL expiry ## Quick start ```sh pip install django psycopg[binary] python-dotenv django-admin startproject config . ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - The all-in-one convenience and the single bill. - The funnel and landing page builder. - Marketing automation with branching and scoring. - Mobile applications and offline lesson downloads. - Affiliate tracking and platform analytics. - all-in-one stack - funnels - email automations - communities - course delivery - analytics - customer support If one of those is essential to you, that is the reason to keep paying for Kajabi, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Kajabi 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 and membership site with payments, gated content and the email sequence that follows a purchase to replace Kajabi. 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) - Framework: Django 5 with Python 3.12 · authentication, an admin for your content, migrations and a security posture on day one - Interface: Django templates with HTMX · course pages are documents, and a frontend framework buys nothing here - Database: PostgreSQL · purchases and access grants are money-adjacent and want a real database - Payments: Stripe Checkout with webhooks · hosted checkout keeps card handling out of your scope entirely - Video: An object store with signed, expiring URLs · the difference between gated video and video anyone can copy the link to - Email: A transactional provider driven by an outbox table · sequences fire from purchase events, and a failed send must be visible rather than lost ### Data model (create this before Phase 1) - products(id, name, slug, price_cents, kind, active) · kind is one_time or subscription - courses(id, product_id, title), modules(id, course_id, title, position), lessons(id, module_id, title, position, video_key, body) - purchases(id, user_id, product_id, stripe_session_id, stripe_subscription_id, state, purchased_at, access_until) - entitlements(user_id, product_id, granted_at, revoked_at, reason) · the only thing any gate consults - progress(user_id, lesson_id, completed_at, seconds_watched) - email_sequences(id, product_id, name), email_steps(sequence_id, position, delay_hours, subject, body), email_outbox(id, user_id, step_id, state, attempts, last_error) - Every gate checks entitlements and nothing else. Purchases write entitlements; they are never consulted directly by a page. ### Phase 1 · Accounts, content and the admin Build: A Django project with accounts, password reset over real email, and the content models registered in the admin. Author one real course with several lessons so every later phase has something to gate. Trap: Django's password reset silently does nothing useful with the console email backend, which looks like success in development. Configure a real transactional provider now, because everything in Phase 5 depends on it and domain verification takes time. Done when: An account can be created, logged out of and back into, and a password reset email arrives at a real inbox. A course with modules and lessons is authored through the admin and renders on the site. The admin is not at the default path and refuses anonymous access. Do not build yet: No payments, no gating, no email sequences. ### Phase 2 · Entitlements as the single gate Build: An entitlements table and one function that answers whether a user may access a product. Every view and template gate calls it. Grant entitlements manually for now, so gating is proven before payments complicate it. Trap: Scattering access checks across views and templates guarantees one of them is wrong, and the wrong one is the page that leaks. One function, called everywhere, with a test that enumerates every gated URL and asserts a refusal for a user without an entitlement. Done when: A user without an entitlement is refused every gated URL, verified by a test that enumerates them rather than by checking a few by hand. Granting an entitlement opens exactly the lessons of that product and no others. A revoked entitlement closes access immediately. Do not build yet: No payments yet, so entitlements are granted by hand. ### Phase 3 · Payments that grant access Build: Stripe Checkout for one-off and subscription products. A webhook endpoint that verifies its signature, records the event identifier for idempotency, and writes the entitlement. For subscriptions, entitlement follows the period rather than ending at the first failed payment. Trap: Granting on the success redirect is the classic mistake, and here it hands out course access to anyone who can replay a URL. The webhook is the only writer of entitlements, and the redirect is a thank-you page that may arrive before the entitlement exists, so it must handle that gracefully. Done when: A test-mode purchase grants access only after the webhook arrives. Replaying the same event leaves one entitlement. A cancelled subscription retains access until the period end and then loses it. A failed subscription payment does not revoke access while Stripe is still retrying. A bad signature is rejected with a 400. Do not build yet: No video protection or email sequences yet. ### Phase 4 · Video that is actually gated Build: Store video in an object store with no public access. Serve short-lived signed URLs generated only after the entitlement check. Set a short expiry and re-sign on demand rather than issuing one long-lived link. Trap: A signed URL with a long expiry is a shareable link to your course for as long as it lasts, and once it is in a group chat you cannot revoke it. Keep expiry short, minutes rather than days, and let the player request a fresh URL when it needs one. Done when: A video URL taken from the page and opened in a private window works within the expiry and fails after it. The object store returns a refusal for an unsigned request. A user without an entitlement receives no URL at all rather than a URL that fails later. Playback continues across the expiry boundary because the player re-signs. Do not build yet: No email sequences yet. ### Phase 5 · Email sequences from real events Build: Sequences of steps with delays, attached to a product. A purchase enqueues the sequence into an outbox. A scheduled command sends due messages, records failures with their error, and retries with backoff up to a cap. Unsubscribe must work. Trap: Sending during the webhook request couples your email provider's availability to Stripe's delivery timeout, and a slow provider turns into a failed webhook and a missing entitlement. Enqueue in the webhook and send from a scheduled job, always. Done when: A purchase enqueues the sequence with correct due times. Running the send command twice sends each message once. A permanently failing address is marked failed after the attempt cap rather than retried forever. An unsubscribe stops further sequence messages but not transactional ones such as a receipt. Do not build yet: Launch hardening is the last phase. ### Phase 6 · Launch, refunds and the restore drill Build: Deploy behind TLS with debug off. Handle refunds and disputes by revoking entitlements from the webhook. Back up the database and the video store off the machine, and restore both onto a clean server. Trap: A refund that leaves the entitlement in place means you have given the course away, and it is easy to miss because the refund path is rarely tested. Handle the refund and dispute events explicitly and test them with the Stripe command line tool before launch, not after the first one. Done when: A refund event revokes the entitlement and the student loses access. A dispute event does the same. The site serves over HTTPS with debug off. A restore onto a clean server reproduces courses, purchases and entitlements, video plays, and the date is recorded. Do not build yet: Nothing after this. ### Out of scope (and why) - The all-in-one convenience. You are now running a website, a payment integration, a video store and an email sender, and each has its own failure mode and its own bill. - The funnel builder and landing page editor, which is a visual tool and a large product in its own right. - Marketing automation with branching, scoring and segmentation. This build sends a linear sequence from a purchase event and stops there. - Mobile applications and offline lesson downloads, which students of paid courses increasingly expect. - The affiliate system and the analytics that come with a mature platform. ### README must contain - That entitlements are the single gate and that every access decision goes through one function - That the Stripe webhook is the only writer of entitlements, including for refunds and disputes - That video URLs are short-lived and signed only after the entitlement check - That sequence email is enqueued in the webhook and sent from a scheduled job - The backup schedule for database and video, and the date of the last restore ===== AGENTS.md ===== # Agent instructions · Kajabi indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Django 5 with Python 3.12, Django templates with HTMX, PostgreSQL, Stripe Checkout with webhooks, An object store with signed, expiring URLs, A transactional provider driven by an outbox table. 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 - Django's password reset silently does nothing useful with the console email backend, which looks like success in development. Configure a real transactional provider now, because everything in Phase 5 depends on it and domain verification takes time. - Scattering access checks across views and templates guarantees one of them is wrong, and the wrong one is the page that leaks. One function, called everywhere, with a test that enumerates every gated URL and asserts a refusal for a user without an entitlement. - Granting on the success redirect is the classic mistake, and here it hands out course access to anyone who can replay a URL. The webhook is the only writer of entitlements, and the redirect is a thank-you page that may arrive before the entitlement exists, so it must handle that gracefully. - A signed URL with a long expiry is a shareable link to your course for as long as it lasts, and once it is in a group chat you cannot revoke it. Keep expiry short, minutes rather than days, and let the player request a fresh URL when it needs one. - Sending during the webhook request couples your email provider's availability to Stripe's delivery timeout, and a slow provider turns into a failed webhook and a missing entitlement. Enqueue in the webhook and send from a scheduled job, always. ===== BUILD_PLAN.md ===== # Build plan · Kajabi A site where lessons live behind a purchase: Stripe checkout for one-off and subscription access, content gated per product, video that cannot simply be downloaded from the page source, progress tracking, and an email sequence that fires from purchase events rather than from a marketing tool you also have to run. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Accounts, content and the admin You can author a course and a student can create an account, before any money moves. ### Steps 1. Create the project with PostgreSQL and real email Settings from environment variables, a transactional provider configured, and a reset email sent to yourself to prove it. Files: `config/settings.py` ```sh pip install django psycopg[binary] python-dotenv django-admin startproject config . ``` 2. Model courses, modules and lessons Ordered with explicit position fields rather than relying on insertion order. Files: `app/models.py` ```sh python manage.py makemigrations && python manage.py migrate ``` 3. Author one real course Not lorem ipsum. Real lessons expose ordering, length and navigation problems that placeholder content hides. ### Done when - [ ] Signup, logout and login all work - [ ] A password reset email reaches a real inbox - [ ] A course renders with modules and lessons in the right order - [ ] The admin refuses anonymous access and is not at the default path ### Watch out - Django's password reset silently does nothing useful with the console email backend, which looks like success in development. Configure a real transactional provider now, because everything in Phase 5 depends on it and domain verification takes time. ## Phase 2 · Entitlements as the single gate Every access decision consults one table, so there is one place to be right. ### Steps 1. Write the single access function User plus product to a boolean, with the reason. Nothing else decides access anywhere in the codebase. Files: `app/access.py` 2. Gate every view through it Views, not templates. A hidden link is not a gate. Files: `app/views.py` 3. Enumerate gated URLs in a test Walk the URL configuration, request every gated route as an entitled and unentitled user, assert both outcomes. Files: `app/tests/test_gates.py` ### Done when - [ ] Every gated URL is refused without an entitlement, proved by an enumerating test - [ ] An entitlement opens exactly its own product's lessons - [ ] Revoking closes access immediately - [ ] No template performs an access decision ### Watch out - Scattering access checks across views and templates guarantees one of them is wrong, and the wrong one is the page that leaks. One function, called everywhere, with a test that enumerates every gated URL and asserts a refusal for a user without an entitlement. ## Phase 3 · Payments that grant access Paying grants an entitlement, and only the webhook believes a payment happened. ### Steps 1. Create Checkout sessions server side Prices from your database, never from the client. Attach the user and product as metadata. Files: `app/payments.py` ```sh pip install stripe ``` 2. Make the webhook the only writer Signature verification, a processed-events table, and entitlement writes. Exempt from CSRF because the signature is the authentication. Files: `app/webhooks.py` 3. Handle the subscription lifecycle Period end for cancellation, terminal state for failure. A scheduled job revokes at period end. Files: `app/subscriptions.py` ### Done when - [ ] Access is granted only after the webhook - [ ] A replayed event leaves one entitlement - [ ] A cancelled subscription keeps access until the period end - [ ] A retrying failed payment does not revoke access ### Watch out - Granting on the success redirect is the classic mistake, and here it hands out course access to anyone who can replay a URL. The webhook is the only writer of entitlements, and the redirect is a thank-you page that may arrive before the entitlement exists, so it must handle that gracefully. ## Phase 4 · Video that is actually gated A paying student can watch, and a non-paying visitor cannot simply copy the link. ### Steps 1. Lock the bucket down No public read. Test with an unsigned request and confirm a refusal before relying on signing. 2. Sign after checking entitlement The entitlement check happens before a URL exists. An unentitled user gets nothing to try. Files: `app/video.py` 3. Re-sign during playback The player requests a fresh URL before expiry so a long lesson does not stop mid-sentence. Files: `static/player.js` ### Done when - [ ] An unsigned request to the object store is refused - [ ] A copied URL stops working after the expiry - [ ] An unentitled user receives no URL at all - [ ] Playback continues across an expiry boundary ### Watch out - A signed URL with a long expiry is a shareable link to your course for as long as it lasts, and once it is in a group chat you cannot revoke it. Keep expiry short, minutes rather than days, and let the player request a fresh URL when it needs one. ## Phase 5 · Email sequences from real events Buying triggers a sequence, and a failed send is a row you can see rather than a message that vanished. ### Steps 1. Model sequences and steps Delays relative to the trigger event, so a three day step means three days after purchase rather than after the previous send. Files: `app/sequences.py` 2. Enqueue in the webhook, send in a job The webhook writes rows and returns. A scheduled command does the sending. Files: `app/management/commands/send_outbox.py` ```sh python manage.py send_outbox ``` 3. Implement unsubscribe properly A signed link, honoured for marketing sequences and not for receipts. Test both cases. Files: `app/unsubscribe.py` ### Done when - [ ] A purchase enqueues the sequence with correct due times - [ ] Running the send command twice sends each message once - [ ] A failing address is capped rather than retried forever - [ ] Unsubscribe stops sequence email and not receipts ### Watch out - Sending during the webhook request couples your email provider's availability to Stripe's delivery timeout, and a slow provider turns into a failed webhook and a missing entitlement. Enqueue in the webhook and send from a scheduled job, always. ## Not in this build - The all-in-one convenience and the single bill. - The funnel and landing page builder. - Marketing automation with branching and scoring. - Mobile applications and offline lesson downloads. - Affiliate tracking and platform analytics. ## After v1, if you want it - Drip release of lessons on a schedule after purchase, which reuses the sequence timing - Certificates of completion generated from the progress table - A coupon system, which Stripe can carry rather than your own code ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Generated once. Changing it invalidates every session and signed value, including unsubscribe links. DJANGO_SECRET_KEY=replace-with-50-random-characters # Required. Zero everywhere but your own machine. DJANGO_DEBUG=0 # Required · secret. Your PostgreSQL connection string. DATABASE_URL=postgres://user:pass@localhost:5432/courses # Required · secret. Stripe dashboard. Stay in test mode until the refund path has been tested. STRIPE_SECRET_KEY=sk_test_... # Required · secret. From the registered endpoint or the listen command. STRIPE_WEBHOOK_SECRET=whsec_... # Required. The private bucket holding lesson video. VIDEO_BUCKET=courses-video # Required. How long a signed video URL lasts. Short, because a long one is a shareable link to your course. VIDEO_URL_EXPIRY_SECONDS=300 # Required · secret. Transactional provider credentials. EMAIL_URL=smtp://user:pass@smtp.provider.com:587
You are building a production product version of Kajabi. 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 ===== # Kajabi · product brief ## Problem You can build one course checkout or landing funnel, but Kajabi bundles site, course, community, email, automations, payments, analytics, and support. ## Product outcome A course site where access is decided in one place, payments and refunds both flow through a verified webhook, video cannot be shared by copying a link, and the post-purchase email sequence retries visibly when it fails. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Stripe - email provider - CMS/course content - auth - hosting - analytics - automations ## Explicit non-goals for v1 - The all-in-one convenience and the single bill. - The funnel and landing page builder. - Marketing automation with branching and scoring. - Mobile applications and offline lesson downloads. - Affiliate tracking and platform analytics. - all-in-one stack - funnels - email automations - communities - course delivery - analytics - customer support ## Success criteria - The enumerating gate test passes for every gated URL - A replayed Stripe event does not double-grant - A refund is confirmed to revoke access - A copied video URL is confirmed to expire - A restore has been performed and a lesson video played from it ===== BRIEF.md ===== # Build brief · Kajabi 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 and membership site with payments, gated content and the email sequence that follows a purchase to replace Kajabi. 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) - Framework: Django 5 with Python 3.12 · authentication, an admin for your content, migrations and a security posture on day one - Interface: Django templates with HTMX · course pages are documents, and a frontend framework buys nothing here - Database: PostgreSQL · purchases and access grants are money-adjacent and want a real database - Payments: Stripe Checkout with webhooks · hosted checkout keeps card handling out of your scope entirely - Video: An object store with signed, expiring URLs · the difference between gated video and video anyone can copy the link to - Email: A transactional provider driven by an outbox table · sequences fire from purchase events, and a failed send must be visible rather than lost ### Data model (create this before Phase 1) - products(id, name, slug, price_cents, kind, active) · kind is one_time or subscription - courses(id, product_id, title), modules(id, course_id, title, position), lessons(id, module_id, title, position, video_key, body) - purchases(id, user_id, product_id, stripe_session_id, stripe_subscription_id, state, purchased_at, access_until) - entitlements(user_id, product_id, granted_at, revoked_at, reason) · the only thing any gate consults - progress(user_id, lesson_id, completed_at, seconds_watched) - email_sequences(id, product_id, name), email_steps(sequence_id, position, delay_hours, subject, body), email_outbox(id, user_id, step_id, state, attempts, last_error) - Every gate checks entitlements and nothing else. Purchases write entitlements; they are never consulted directly by a page. ### Phase 1 · Accounts, content and the admin Build: A Django project with accounts, password reset over real email, and the content models registered in the admin. Author one real course with several lessons so every later phase has something to gate. Trap: Django's password reset silently does nothing useful with the console email backend, which looks like success in development. Configure a real transactional provider now, because everything in Phase 5 depends on it and domain verification takes time. Done when: An account can be created, logged out of and back into, and a password reset email arrives at a real inbox. A course with modules and lessons is authored through the admin and renders on the site. The admin is not at the default path and refuses anonymous access. Do not build yet: No payments, no gating, no email sequences. ### Phase 2 · Entitlements as the single gate Build: An entitlements table and one function that answers whether a user may access a product. Every view and template gate calls it. Grant entitlements manually for now, so gating is proven before payments complicate it. Trap: Scattering access checks across views and templates guarantees one of them is wrong, and the wrong one is the page that leaks. One function, called everywhere, with a test that enumerates every gated URL and asserts a refusal for a user without an entitlement. Done when: A user without an entitlement is refused every gated URL, verified by a test that enumerates them rather than by checking a few by hand. Granting an entitlement opens exactly the lessons of that product and no others. A revoked entitlement closes access immediately. Do not build yet: No payments yet, so entitlements are granted by hand. ### Phase 3 · Payments that grant access Build: Stripe Checkout for one-off and subscription products. A webhook endpoint that verifies its signature, records the event identifier for idempotency, and writes the entitlement. For subscriptions, entitlement follows the period rather than ending at the first failed payment. Trap: Granting on the success redirect is the classic mistake, and here it hands out course access to anyone who can replay a URL. The webhook is the only writer of entitlements, and the redirect is a thank-you page that may arrive before the entitlement exists, so it must handle that gracefully. Done when: A test-mode purchase grants access only after the webhook arrives. Replaying the same event leaves one entitlement. A cancelled subscription retains access until the period end and then loses it. A failed subscription payment does not revoke access while Stripe is still retrying. A bad signature is rejected with a 400. Do not build yet: No video protection or email sequences yet. ### Phase 4 · Video that is actually gated Build: Store video in an object store with no public access. Serve short-lived signed URLs generated only after the entitlement check. Set a short expiry and re-sign on demand rather than issuing one long-lived link. Trap: A signed URL with a long expiry is a shareable link to your course for as long as it lasts, and once it is in a group chat you cannot revoke it. Keep expiry short, minutes rather than days, and let the player request a fresh URL when it needs one. Done when: A video URL taken from the page and opened in a private window works within the expiry and fails after it. The object store returns a refusal for an unsigned request. A user without an entitlement receives no URL at all rather than a URL that fails later. Playback continues across the expiry boundary because the player re-signs. Do not build yet: No email sequences yet. ### Phase 5 · Email sequences from real events Build: Sequences of steps with delays, attached to a product. A purchase enqueues the sequence into an outbox. A scheduled command sends due messages, records failures with their error, and retries with backoff up to a cap. Unsubscribe must work. Trap: Sending during the webhook request couples your email provider's availability to Stripe's delivery timeout, and a slow provider turns into a failed webhook and a missing entitlement. Enqueue in the webhook and send from a scheduled job, always. Done when: A purchase enqueues the sequence with correct due times. Running the send command twice sends each message once. A permanently failing address is marked failed after the attempt cap rather than retried forever. An unsubscribe stops further sequence messages but not transactional ones such as a receipt. Do not build yet: Launch hardening is the last phase. ### Phase 6 · Launch, refunds and the restore drill Build: Deploy behind TLS with debug off. Handle refunds and disputes by revoking entitlements from the webhook. Back up the database and the video store off the machine, and restore both onto a clean server. Trap: A refund that leaves the entitlement in place means you have given the course away, and it is easy to miss because the refund path is rarely tested. Handle the refund and dispute events explicitly and test them with the Stripe command line tool before launch, not after the first one. Done when: A refund event revokes the entitlement and the student loses access. A dispute event does the same. The site serves over HTTPS with debug off. A restore onto a clean server reproduces courses, purchases and entitlements, video plays, and the date is recorded. Do not build yet: Nothing after this. ### Out of scope (and why) - The all-in-one convenience. You are now running a website, a payment integration, a video store and an email sender, and each has its own failure mode and its own bill. - The funnel builder and landing page editor, which is a visual tool and a large product in its own right. - Marketing automation with branching, scoring and segmentation. This build sends a linear sequence from a purchase event and stops there. - Mobile applications and offline lesson downloads, which students of paid courses increasingly expect. - The affiliate system and the analytics that come with a mature platform. ### README must contain - That entitlements are the single gate and that every access decision goes through one function - That the Stripe webhook is the only writer of entitlements, including for refunds and disputes - That video URLs are short-lived and signed only after the entitlement check - That sequence email is enqueued in the webhook and sent from a scheduled job - The backup schedule for database and video, and the date of the last restore ===== ARCHITECTURE.md ===== # Architecture · Kajabi ## Stack | Part | Choice | Why | | --- | --- | --- | | Framework | Django 5 with Python 3.12 | authentication, an admin for your content, migrations and a security posture on day one | | Interface | Django templates with HTMX | course pages are documents, and a frontend framework buys nothing here | | Database | PostgreSQL | purchases and access grants are money-adjacent and want a real database | | Payments | Stripe Checkout with webhooks | hosted checkout keeps card handling out of your scope entirely | | Video | An object store with signed, expiring URLs | the difference between gated video and video anyone can copy the link to | | Email | A transactional provider driven by an outbox table | sequences fire from purchase events, and a failed send must be visible rather than lost | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Entitlements | Every access decision. | One function, one table. If a second place ever decides access, that is the bug, regardless of what else changes. | | Payments | Checkout, webhooks and the subscription lifecycle. | Another processor changes the session call and the signature check. The rule that only the webhook writes entitlements survives. | | Video | Private storage and short-lived signed URLs. | Any S3-compatible store, or a video platform with signed playback. Short expiry with re-signing is the property that matters. | | Sequences | Scheduled email from purchase events. | A marketing tool could take this over, at the cost of another subscription and another integration. | | Outbox | Deferred sending with retries and visible failure. | A queue is the upgrade. The visible failed state is not optional. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DJANGO_SECRET_KEY` · required, secret · Generated once. Changing it invalidates every session and signed value, including unsubscribe links. - `DJANGO_DEBUG` · required · Zero everywhere but your own machine. - `DATABASE_URL` · required, secret · Your PostgreSQL connection string. - `STRIPE_SECRET_KEY` · required, secret · Stripe dashboard. Stay in test mode until the refund path has been tested. - `STRIPE_WEBHOOK_SECRET` · required, secret · From the registered endpoint or the listen command. - `VIDEO_BUCKET` · required · The private bucket holding lesson video. - `VIDEO_URL_EXPIRY_SECONDS` · required · How long a signed video URL lasts. Short, because a long one is a shareable link to your course. - `EMAIL_URL` · required, secret · Transactional provider credentials. ## 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 · Kajabi product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Django 5 with Python 3.12, Django templates with HTMX, PostgreSQL, Stripe Checkout with webhooks, An object store with signed, expiring URLs, A transactional provider driven by an outbox table. - 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 - Django's password reset silently does nothing useful with the console email backend, which looks like success in development. Configure a real transactional provider now, because everything in Phase 5 depends on it and domain verification takes time. - Scattering access checks across views and templates guarantees one of them is wrong, and the wrong one is the page that leaks. One function, called everywhere, with a test that enumerates every gated URL and asserts a refusal for a user without an entitlement. - Granting on the success redirect is the classic mistake, and here it hands out course access to anyone who can replay a URL. The webhook is the only writer of entitlements, and the redirect is a thank-you page that may arrive before the entitlement exists, so it must handle that gracefully. - A signed URL with a long expiry is a shareable link to your course for as long as it lasts, and once it is in a group chat you cannot revoke it. Keep expiry short, minutes rather than days, and let the player request a fresh URL when it needs one. - Sending during the webhook request couples your email provider's availability to Stripe's delivery timeout, and a slow provider turns into a failed webhook and a missing entitlement. Enqueue in the webhook and send from a scheduled job, always. - A refund that leaves the entitlement in place means you have given the course away, and it is easy to miss because the refund path is rarely tested. Handle the refund and dispute events explicitly and test them with the Stripe command line tool before launch, not after the first one. ===== MILESTONES.md ===== # Delivery milestones · Kajabi Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Accounts, content and the admin You can author a course and a student can create an account, before any money moves. ### Steps 1. Create the project with PostgreSQL and real email Settings from environment variables, a transactional provider configured, and a reset email sent to yourself to prove it. Files: `config/settings.py` ```sh pip install django psycopg[binary] python-dotenv django-admin startproject config . ``` 2. Model courses, modules and lessons Ordered with explicit position fields rather than relying on insertion order. Files: `app/models.py` ```sh python manage.py makemigrations && python manage.py migrate ``` 3. Author one real course Not lorem ipsum. Real lessons expose ordering, length and navigation problems that placeholder content hides. ### Done when - [ ] Signup, logout and login all work - [ ] A password reset email reaches a real inbox - [ ] A course renders with modules and lessons in the right order - [ ] The admin refuses anonymous access and is not at the default path ### Watch out - Django's password reset silently does nothing useful with the console email backend, which looks like success in development. Configure a real transactional provider now, because everything in Phase 5 depends on it and domain verification takes time. ## M2 · Entitlements as the single gate Every access decision consults one table, so there is one place to be right. ### Steps 1. Write the single access function User plus product to a boolean, with the reason. Nothing else decides access anywhere in the codebase. Files: `app/access.py` 2. Gate every view through it Views, not templates. A hidden link is not a gate. Files: `app/views.py` 3. Enumerate gated URLs in a test Walk the URL configuration, request every gated route as an entitled and unentitled user, assert both outcomes. Files: `app/tests/test_gates.py` ### Done when - [ ] Every gated URL is refused without an entitlement, proved by an enumerating test - [ ] An entitlement opens exactly its own product's lessons - [ ] Revoking closes access immediately - [ ] No template performs an access decision ### Watch out - Scattering access checks across views and templates guarantees one of them is wrong, and the wrong one is the page that leaks. One function, called everywhere, with a test that enumerates every gated URL and asserts a refusal for a user without an entitlement. ## M3 · Payments that grant access Paying grants an entitlement, and only the webhook believes a payment happened. ### Steps 1. Create Checkout sessions server side Prices from your database, never from the client. Attach the user and product as metadata. Files: `app/payments.py` ```sh pip install stripe ``` 2. Make the webhook the only writer Signature verification, a processed-events table, and entitlement writes. Exempt from CSRF because the signature is the authentication. Files: `app/webhooks.py` 3. Handle the subscription lifecycle Period end for cancellation, terminal state for failure. A scheduled job revokes at period end. Files: `app/subscriptions.py` ### Done when - [ ] Access is granted only after the webhook - [ ] A replayed event leaves one entitlement - [ ] A cancelled subscription keeps access until the period end - [ ] A retrying failed payment does not revoke access ### Watch out - Granting on the success redirect is the classic mistake, and here it hands out course access to anyone who can replay a URL. The webhook is the only writer of entitlements, and the redirect is a thank-you page that may arrive before the entitlement exists, so it must handle that gracefully. ## M4 · Video that is actually gated A paying student can watch, and a non-paying visitor cannot simply copy the link. ### Steps 1. Lock the bucket down No public read. Test with an unsigned request and confirm a refusal before relying on signing. 2. Sign after checking entitlement The entitlement check happens before a URL exists. An unentitled user gets nothing to try. Files: `app/video.py` 3. Re-sign during playback The player requests a fresh URL before expiry so a long lesson does not stop mid-sentence. Files: `static/player.js` ### Done when - [ ] An unsigned request to the object store is refused - [ ] A copied URL stops working after the expiry - [ ] An unentitled user receives no URL at all - [ ] Playback continues across an expiry boundary ### Watch out - A signed URL with a long expiry is a shareable link to your course for as long as it lasts, and once it is in a group chat you cannot revoke it. Keep expiry short, minutes rather than days, and let the player request a fresh URL when it needs one. ## M5 · Email sequences from real events Buying triggers a sequence, and a failed send is a row you can see rather than a message that vanished. ### Steps 1. Model sequences and steps Delays relative to the trigger event, so a three day step means three days after purchase rather than after the previous send. Files: `app/sequences.py` 2. Enqueue in the webhook, send in a job The webhook writes rows and returns. A scheduled command does the sending. Files: `app/management/commands/send_outbox.py` ```sh python manage.py send_outbox ``` 3. Implement unsubscribe properly A signed link, honoured for marketing sequences and not for receipts. Test both cases. Files: `app/unsubscribe.py` ### Done when - [ ] A purchase enqueues the sequence with correct due times - [ ] Running the send command twice sends each message once - [ ] A failing address is capped rather than retried forever - [ ] Unsubscribe stops sequence email and not receipts ### Watch out - Sending during the webhook request couples your email provider's availability to Stripe's delivery timeout, and a slow provider turns into a failed webhook and a missing entitlement. Enqueue in the webhook and send from a scheduled job, always. ## M6 · Launch, refunds and the restore drill (production only) It runs in production, handles a refund correctly, and you have restored it. ### Steps 1. Handle refunds and disputes Explicit webhook handlers that revoke. Trigger both with the Stripe command line tool and verify access is lost. Files: `app/webhooks.py` ```sh stripe trigger charge.refunded ``` 2. Deploy properly TLS, debug off, allowed hosts set, static files served by the proxy, secrets from the environment. Files: `Caddyfile` ```sh python manage.py collectstatic ``` 3. Back up both and restore Database and video store, dated, off the machine. Restore onto a clean server, play a video, record the date. Files: `README.md` ### Done when - [ ] A refund revokes the entitlement and access is lost - [ ] A dispute does the same - [ ] The site serves over HTTPS with debug off - [ ] A restore reproduces access and playable video, and is dated ### Watch out - A refund that leaves the entitlement in place means you have given the course away, and it is easy to miss because the refund path is rarely tested. Handle the refund and dispute events explicitly and test them with the Stripe command line tool before launch, not after the first one. ===== OPERATIONS.md ===== # Operations · Kajabi ## Backup Nightly database dump plus the video store, both off the machine. Video is large, so a separate schedule is reasonable, but a database restore without video gives you a site of broken lessons. ## Restore Restore the database, confirm the video store is reachable, sign a URL and play a lesson. That last step is the one that proves the restore, because it exercises both halves. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Webhook failures, outbox rows older than an hour, and video egress. A rising outbox age means email broke, which you will otherwise learn from a student. ## Incident checklist For a missed refund, the entitlement is still active and a student has free access; check the webhook handler first. For a video that will not play, check the clock skew on the signing machine before anything else, since signed URLs are time sensitive. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] The enumerating gate test passes for every gated URL - [ ] A replayed Stripe event does not double-grant - [ ] A refund is confirmed to revoke access - [ ] A copied video URL is confirmed to expire - [ ] A restore has been performed and a lesson video played from it ## Launch constraint Do not market omitted Kajabi 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. Generated once. Changing it invalidates every session and signed value, including unsubscribe links. DJANGO_SECRET_KEY=replace-with-50-random-characters # Required. Zero everywhere but your own machine. DJANGO_DEBUG=0 # Required · secret. Your PostgreSQL connection string. DATABASE_URL=postgres://user:pass@localhost:5432/courses # Required · secret. Stripe dashboard. Stay in test mode until the refund path has been tested. STRIPE_SECRET_KEY=sk_test_... # Required · secret. From the registered endpoint or the listen command. STRIPE_WEBHOOK_SECRET=whsec_... # Required. The private bucket holding lesson video. VIDEO_BUCKET=courses-video # Required. How long a signed video URL lasts. Short, because a long one is a shareable link to your course. VIDEO_URL_EXPIRY_SECONDS=300 # Required · secret. Transactional provider credentials. EMAIL_URL=smtp://user:pass@smtp.provider.com:587
# Kajabi · indie build A site where lessons live behind a purchase: Stripe checkout for one-off and subscription access, content gated per product, video that cannot simply be downloaded from the page source, progress tracking, and an email sequence that fires from purchase events rather than from a marketing tool you also have to run. 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 | | --- | --- | --- | | Framework | Django 5 with Python 3.12 | authentication, an admin for your content, migrations and a security posture on day one | | Interface | Django templates with HTMX | course pages are documents, and a frontend framework buys nothing here | | Database | PostgreSQL | purchases and access grants are money-adjacent and want a real database | | Payments | Stripe Checkout with webhooks | hosted checkout keeps card handling out of your scope entirely | | Video | An object store with signed, expiring URLs | the difference between gated video and video anyone can copy the link to | | Email | A transactional provider driven by an outbox table | sequences fire from purchase events, and a failed send must be visible rather than lost | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Python 3.12 or newer** · free - Why: Runs Django. - Get it: python.org or your package manager, then a virtual environment. - Verify: python3 --version prints 3.12 or higher - [ ] **PostgreSQL 16 or newer** · free - Why: Holds purchases and entitlements, which are money-adjacent and deserve a real database. - Get it: Install locally or run it in Docker, then create a database for the project. - Verify: psql connects and reports 16 or higher - [ ] **A Stripe account with test mode keys** · free in test mode; live payments cost per transaction plus an additional fee on subscriptions in most regions - Why: Checkout, subscriptions, refunds and disputes all come from here, and test mode covers every one of them. - Get it: Sign up at stripe.com, take the secret key from Developers then API keys, and the webhook signing secret from your endpoint or from the listen command. - Verify: A triggered test event reaches your endpoint and is verified - [ ] **An S3-compatible object store** · storage is cheap; egress is the real cost and scales with how much students watch - Why: Video must not be publicly readable, and signed URLs are how a paying student watches without the link being shareable. - Get it: Create a private bucket with any provider, and credentials scoped to it. Confirm the provider supports presigned URLs with a short expiry. - Verify: An unsigned request to an object is refused and a signed one succeeds - [ ] **A transactional email provider with a verified domain** · free tiers usually cover a few thousand messages a month - Why: Password reset, receipts and sequences all need delivery. Domain verification is the slow step. - Get it: Create an account, add the DNS records they specify, and take the credentials. Start this before Phase 1 because propagation takes hours. - Verify: A password reset email reaches an inbox and is not in spam - [ ] **One real course with several lessons** · free - Why: Placeholder content hides ordering, navigation and video length problems that appear immediately with real material. - Get it: Use a course you have already written, or write three real lessons. Include at least one video long enough to cross a signed URL expiry. - Verify: The course is authored and one lesson's video exceeds the URL expiry ## Quick start ```sh pip install django psycopg[binary] python-dotenv django-admin startproject config . ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - The all-in-one convenience and the single bill. - The funnel and landing page builder. - Marketing automation with branching and scoring. - Mobile applications and offline lesson downloads. - Affiliate tracking and platform analytics. - all-in-one stack - funnels - email automations - communities - course delivery - analytics - customer support If one of those is essential to you, that is the reason to keep paying for Kajabi, and the README should say so rather than pretend.
# Build brief · Kajabi 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 and membership site with payments, gated content and the email sequence that follows a purchase to replace Kajabi. 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) - Framework: Django 5 with Python 3.12 · authentication, an admin for your content, migrations and a security posture on day one - Interface: Django templates with HTMX · course pages are documents, and a frontend framework buys nothing here - Database: PostgreSQL · purchases and access grants are money-adjacent and want a real database - Payments: Stripe Checkout with webhooks · hosted checkout keeps card handling out of your scope entirely - Video: An object store with signed, expiring URLs · the difference between gated video and video anyone can copy the link to - Email: A transactional provider driven by an outbox table · sequences fire from purchase events, and a failed send must be visible rather than lost ### Data model (create this before Phase 1) - products(id, name, slug, price_cents, kind, active) · kind is one_time or subscription - courses(id, product_id, title), modules(id, course_id, title, position), lessons(id, module_id, title, position, video_key, body) - purchases(id, user_id, product_id, stripe_session_id, stripe_subscription_id, state, purchased_at, access_until) - entitlements(user_id, product_id, granted_at, revoked_at, reason) · the only thing any gate consults - progress(user_id, lesson_id, completed_at, seconds_watched) - email_sequences(id, product_id, name), email_steps(sequence_id, position, delay_hours, subject, body), email_outbox(id, user_id, step_id, state, attempts, last_error) - Every gate checks entitlements and nothing else. Purchases write entitlements; they are never consulted directly by a page. ### Phase 1 · Accounts, content and the admin Build: A Django project with accounts, password reset over real email, and the content models registered in the admin. Author one real course with several lessons so every later phase has something to gate. Trap: Django's password reset silently does nothing useful with the console email backend, which looks like success in development. Configure a real transactional provider now, because everything in Phase 5 depends on it and domain verification takes time. Done when: An account can be created, logged out of and back into, and a password reset email arrives at a real inbox. A course with modules and lessons is authored through the admin and renders on the site. The admin is not at the default path and refuses anonymous access. Do not build yet: No payments, no gating, no email sequences. ### Phase 2 · Entitlements as the single gate Build: An entitlements table and one function that answers whether a user may access a product. Every view and template gate calls it. Grant entitlements manually for now, so gating is proven before payments complicate it. Trap: Scattering access checks across views and templates guarantees one of them is wrong, and the wrong one is the page that leaks. One function, called everywhere, with a test that enumerates every gated URL and asserts a refusal for a user without an entitlement. Done when: A user without an entitlement is refused every gated URL, verified by a test that enumerates them rather than by checking a few by hand. Granting an entitlement opens exactly the lessons of that product and no others. A revoked entitlement closes access immediately. Do not build yet: No payments yet, so entitlements are granted by hand. ### Phase 3 · Payments that grant access Build: Stripe Checkout for one-off and subscription products. A webhook endpoint that verifies its signature, records the event identifier for idempotency, and writes the entitlement. For subscriptions, entitlement follows the period rather than ending at the first failed payment. Trap: Granting on the success redirect is the classic mistake, and here it hands out course access to anyone who can replay a URL. The webhook is the only writer of entitlements, and the redirect is a thank-you page that may arrive before the entitlement exists, so it must handle that gracefully. Done when: A test-mode purchase grants access only after the webhook arrives. Replaying the same event leaves one entitlement. A cancelled subscription retains access until the period end and then loses it. A failed subscription payment does not revoke access while Stripe is still retrying. A bad signature is rejected with a 400. Do not build yet: No video protection or email sequences yet. ### Phase 4 · Video that is actually gated Build: Store video in an object store with no public access. Serve short-lived signed URLs generated only after the entitlement check. Set a short expiry and re-sign on demand rather than issuing one long-lived link. Trap: A signed URL with a long expiry is a shareable link to your course for as long as it lasts, and once it is in a group chat you cannot revoke it. Keep expiry short, minutes rather than days, and let the player request a fresh URL when it needs one. Done when: A video URL taken from the page and opened in a private window works within the expiry and fails after it. The object store returns a refusal for an unsigned request. A user without an entitlement receives no URL at all rather than a URL that fails later. Playback continues across the expiry boundary because the player re-signs. Do not build yet: No email sequences yet. ### Phase 5 · Email sequences from real events Build: Sequences of steps with delays, attached to a product. A purchase enqueues the sequence into an outbox. A scheduled command sends due messages, records failures with their error, and retries with backoff up to a cap. Unsubscribe must work. Trap: Sending during the webhook request couples your email provider's availability to Stripe's delivery timeout, and a slow provider turns into a failed webhook and a missing entitlement. Enqueue in the webhook and send from a scheduled job, always. Done when: A purchase enqueues the sequence with correct due times. Running the send command twice sends each message once. A permanently failing address is marked failed after the attempt cap rather than retried forever. An unsubscribe stops further sequence messages but not transactional ones such as a receipt. Do not build yet: Launch hardening is the last phase. ### Phase 6 · Launch, refunds and the restore drill Build: Deploy behind TLS with debug off. Handle refunds and disputes by revoking entitlements from the webhook. Back up the database and the video store off the machine, and restore both onto a clean server. Trap: A refund that leaves the entitlement in place means you have given the course away, and it is easy to miss because the refund path is rarely tested. Handle the refund and dispute events explicitly and test them with the Stripe command line tool before launch, not after the first one. Done when: A refund event revokes the entitlement and the student loses access. A dispute event does the same. The site serves over HTTPS with debug off. A restore onto a clean server reproduces courses, purchases and entitlements, video plays, and the date is recorded. Do not build yet: Nothing after this. ### Out of scope (and why) - The all-in-one convenience. You are now running a website, a payment integration, a video store and an email sender, and each has its own failure mode and its own bill. - The funnel builder and landing page editor, which is a visual tool and a large product in its own right. - Marketing automation with branching, scoring and segmentation. This build sends a linear sequence from a purchase event and stops there. - Mobile applications and offline lesson downloads, which students of paid courses increasingly expect. - The affiliate system and the analytics that come with a mature platform. ### README must contain - That entitlements are the single gate and that every access decision goes through one function - That the Stripe webhook is the only writer of entitlements, including for refunds and disputes - That video URLs are short-lived and signed only after the entitlement check - That sequence email is enqueued in the webhook and sent from a scheduled job - The backup schedule for database and video, and the date of the last restore
# Agent instructions · Kajabi indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Django 5 with Python 3.12, Django templates with HTMX, PostgreSQL, Stripe Checkout with webhooks, An object store with signed, expiring URLs, A transactional provider driven by an outbox table. 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 - Django's password reset silently does nothing useful with the console email backend, which looks like success in development. Configure a real transactional provider now, because everything in Phase 5 depends on it and domain verification takes time. - Scattering access checks across views and templates guarantees one of them is wrong, and the wrong one is the page that leaks. One function, called everywhere, with a test that enumerates every gated URL and asserts a refusal for a user without an entitlement. - Granting on the success redirect is the classic mistake, and here it hands out course access to anyone who can replay a URL. The webhook is the only writer of entitlements, and the redirect is a thank-you page that may arrive before the entitlement exists, so it must handle that gracefully. - A signed URL with a long expiry is a shareable link to your course for as long as it lasts, and once it is in a group chat you cannot revoke it. Keep expiry short, minutes rather than days, and let the player request a fresh URL when it needs one. - Sending during the webhook request couples your email provider's availability to Stripe's delivery timeout, and a slow provider turns into a failed webhook and a missing entitlement. Enqueue in the webhook and send from a scheduled job, always.
# Build plan · Kajabi A site where lessons live behind a purchase: Stripe checkout for one-off and subscription access, content gated per product, video that cannot simply be downloaded from the page source, progress tracking, and an email sequence that fires from purchase events rather than from a marketing tool you also have to run. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Accounts, content and the admin You can author a course and a student can create an account, before any money moves. ### Steps 1. Create the project with PostgreSQL and real email Settings from environment variables, a transactional provider configured, and a reset email sent to yourself to prove it. Files: `config/settings.py` ```sh pip install django psycopg[binary] python-dotenv django-admin startproject config . ``` 2. Model courses, modules and lessons Ordered with explicit position fields rather than relying on insertion order. Files: `app/models.py` ```sh python manage.py makemigrations && python manage.py migrate ``` 3. Author one real course Not lorem ipsum. Real lessons expose ordering, length and navigation problems that placeholder content hides. ### Done when - [ ] Signup, logout and login all work - [ ] A password reset email reaches a real inbox - [ ] A course renders with modules and lessons in the right order - [ ] The admin refuses anonymous access and is not at the default path ### Watch out - Django's password reset silently does nothing useful with the console email backend, which looks like success in development. Configure a real transactional provider now, because everything in Phase 5 depends on it and domain verification takes time. ## Phase 2 · Entitlements as the single gate Every access decision consults one table, so there is one place to be right. ### Steps 1. Write the single access function User plus product to a boolean, with the reason. Nothing else decides access anywhere in the codebase. Files: `app/access.py` 2. Gate every view through it Views, not templates. A hidden link is not a gate. Files: `app/views.py` 3. Enumerate gated URLs in a test Walk the URL configuration, request every gated route as an entitled and unentitled user, assert both outcomes. Files: `app/tests/test_gates.py` ### Done when - [ ] Every gated URL is refused without an entitlement, proved by an enumerating test - [ ] An entitlement opens exactly its own product's lessons - [ ] Revoking closes access immediately - [ ] No template performs an access decision ### Watch out - Scattering access checks across views and templates guarantees one of them is wrong, and the wrong one is the page that leaks. One function, called everywhere, with a test that enumerates every gated URL and asserts a refusal for a user without an entitlement. ## Phase 3 · Payments that grant access Paying grants an entitlement, and only the webhook believes a payment happened. ### Steps 1. Create Checkout sessions server side Prices from your database, never from the client. Attach the user and product as metadata. Files: `app/payments.py` ```sh pip install stripe ``` 2. Make the webhook the only writer Signature verification, a processed-events table, and entitlement writes. Exempt from CSRF because the signature is the authentication. Files: `app/webhooks.py` 3. Handle the subscription lifecycle Period end for cancellation, terminal state for failure. A scheduled job revokes at period end. Files: `app/subscriptions.py` ### Done when - [ ] Access is granted only after the webhook - [ ] A replayed event leaves one entitlement - [ ] A cancelled subscription keeps access until the period end - [ ] A retrying failed payment does not revoke access ### Watch out - Granting on the success redirect is the classic mistake, and here it hands out course access to anyone who can replay a URL. The webhook is the only writer of entitlements, and the redirect is a thank-you page that may arrive before the entitlement exists, so it must handle that gracefully. ## Phase 4 · Video that is actually gated A paying student can watch, and a non-paying visitor cannot simply copy the link. ### Steps 1. Lock the bucket down No public read. Test with an unsigned request and confirm a refusal before relying on signing. 2. Sign after checking entitlement The entitlement check happens before a URL exists. An unentitled user gets nothing to try. Files: `app/video.py` 3. Re-sign during playback The player requests a fresh URL before expiry so a long lesson does not stop mid-sentence. Files: `static/player.js` ### Done when - [ ] An unsigned request to the object store is refused - [ ] A copied URL stops working after the expiry - [ ] An unentitled user receives no URL at all - [ ] Playback continues across an expiry boundary ### Watch out - A signed URL with a long expiry is a shareable link to your course for as long as it lasts, and once it is in a group chat you cannot revoke it. Keep expiry short, minutes rather than days, and let the player request a fresh URL when it needs one. ## Phase 5 · Email sequences from real events Buying triggers a sequence, and a failed send is a row you can see rather than a message that vanished. ### Steps 1. Model sequences and steps Delays relative to the trigger event, so a three day step means three days after purchase rather than after the previous send. Files: `app/sequences.py` 2. Enqueue in the webhook, send in a job The webhook writes rows and returns. A scheduled command does the sending. Files: `app/management/commands/send_outbox.py` ```sh python manage.py send_outbox ``` 3. Implement unsubscribe properly A signed link, honoured for marketing sequences and not for receipts. Test both cases. Files: `app/unsubscribe.py` ### Done when - [ ] A purchase enqueues the sequence with correct due times - [ ] Running the send command twice sends each message once - [ ] A failing address is capped rather than retried forever - [ ] Unsubscribe stops sequence email and not receipts ### Watch out - Sending during the webhook request couples your email provider's availability to Stripe's delivery timeout, and a slow provider turns into a failed webhook and a missing entitlement. Enqueue in the webhook and send from a scheduled job, always. ## Not in this build - The all-in-one convenience and the single bill. - The funnel and landing page builder. - Marketing automation with branching and scoring. - Mobile applications and offline lesson downloads. - Affiliate tracking and platform analytics. ## After v1, if you want it - Drip release of lessons on a schedule after purchase, which reuses the sequence timing - Certificates of completion generated from the progress table - A coupon system, which Stripe can carry rather than your own code
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Generated once. Changing it invalidates every session and signed value, including unsubscribe links. DJANGO_SECRET_KEY=replace-with-50-random-characters # Required. Zero everywhere but your own machine. DJANGO_DEBUG=0 # Required · secret. Your PostgreSQL connection string. DATABASE_URL=postgres://user:pass@localhost:5432/courses # Required · secret. Stripe dashboard. Stay in test mode until the refund path has been tested. STRIPE_SECRET_KEY=sk_test_... # Required · secret. From the registered endpoint or the listen command. STRIPE_WEBHOOK_SECRET=whsec_... # Required. The private bucket holding lesson video. VIDEO_BUCKET=courses-video # Required. How long a signed video URL lasts. Short, because a long one is a shareable link to your course. VIDEO_URL_EXPIRY_SECONDS=300 # Required · secret. Transactional provider credentials. EMAIL_URL=smtp://user:pass@smtp.provider.com:587
# Kajabi · product brief ## Problem You can build one course checkout or landing funnel, but Kajabi bundles site, course, community, email, automations, payments, analytics, and support. ## Product outcome A course site where access is decided in one place, payments and refunds both flow through a verified webhook, video cannot be shared by copying a link, and the post-purchase email sequence retries visibly when it fails. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Stripe - email provider - CMS/course content - auth - hosting - analytics - automations ## Explicit non-goals for v1 - The all-in-one convenience and the single bill. - The funnel and landing page builder. - Marketing automation with branching and scoring. - Mobile applications and offline lesson downloads. - Affiliate tracking and platform analytics. - all-in-one stack - funnels - email automations - communities - course delivery - analytics - customer support ## Success criteria - The enumerating gate test passes for every gated URL - A replayed Stripe event does not double-grant - A refund is confirmed to revoke access - A copied video URL is confirmed to expire - A restore has been performed and a lesson video played from it
# Build brief · Kajabi 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 and membership site with payments, gated content and the email sequence that follows a purchase to replace Kajabi. 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) - Framework: Django 5 with Python 3.12 · authentication, an admin for your content, migrations and a security posture on day one - Interface: Django templates with HTMX · course pages are documents, and a frontend framework buys nothing here - Database: PostgreSQL · purchases and access grants are money-adjacent and want a real database - Payments: Stripe Checkout with webhooks · hosted checkout keeps card handling out of your scope entirely - Video: An object store with signed, expiring URLs · the difference between gated video and video anyone can copy the link to - Email: A transactional provider driven by an outbox table · sequences fire from purchase events, and a failed send must be visible rather than lost ### Data model (create this before Phase 1) - products(id, name, slug, price_cents, kind, active) · kind is one_time or subscription - courses(id, product_id, title), modules(id, course_id, title, position), lessons(id, module_id, title, position, video_key, body) - purchases(id, user_id, product_id, stripe_session_id, stripe_subscription_id, state, purchased_at, access_until) - entitlements(user_id, product_id, granted_at, revoked_at, reason) · the only thing any gate consults - progress(user_id, lesson_id, completed_at, seconds_watched) - email_sequences(id, product_id, name), email_steps(sequence_id, position, delay_hours, subject, body), email_outbox(id, user_id, step_id, state, attempts, last_error) - Every gate checks entitlements and nothing else. Purchases write entitlements; they are never consulted directly by a page. ### Phase 1 · Accounts, content and the admin Build: A Django project with accounts, password reset over real email, and the content models registered in the admin. Author one real course with several lessons so every later phase has something to gate. Trap: Django's password reset silently does nothing useful with the console email backend, which looks like success in development. Configure a real transactional provider now, because everything in Phase 5 depends on it and domain verification takes time. Done when: An account can be created, logged out of and back into, and a password reset email arrives at a real inbox. A course with modules and lessons is authored through the admin and renders on the site. The admin is not at the default path and refuses anonymous access. Do not build yet: No payments, no gating, no email sequences. ### Phase 2 · Entitlements as the single gate Build: An entitlements table and one function that answers whether a user may access a product. Every view and template gate calls it. Grant entitlements manually for now, so gating is proven before payments complicate it. Trap: Scattering access checks across views and templates guarantees one of them is wrong, and the wrong one is the page that leaks. One function, called everywhere, with a test that enumerates every gated URL and asserts a refusal for a user without an entitlement. Done when: A user without an entitlement is refused every gated URL, verified by a test that enumerates them rather than by checking a few by hand. Granting an entitlement opens exactly the lessons of that product and no others. A revoked entitlement closes access immediately. Do not build yet: No payments yet, so entitlements are granted by hand. ### Phase 3 · Payments that grant access Build: Stripe Checkout for one-off and subscription products. A webhook endpoint that verifies its signature, records the event identifier for idempotency, and writes the entitlement. For subscriptions, entitlement follows the period rather than ending at the first failed payment. Trap: Granting on the success redirect is the classic mistake, and here it hands out course access to anyone who can replay a URL. The webhook is the only writer of entitlements, and the redirect is a thank-you page that may arrive before the entitlement exists, so it must handle that gracefully. Done when: A test-mode purchase grants access only after the webhook arrives. Replaying the same event leaves one entitlement. A cancelled subscription retains access until the period end and then loses it. A failed subscription payment does not revoke access while Stripe is still retrying. A bad signature is rejected with a 400. Do not build yet: No video protection or email sequences yet. ### Phase 4 · Video that is actually gated Build: Store video in an object store with no public access. Serve short-lived signed URLs generated only after the entitlement check. Set a short expiry and re-sign on demand rather than issuing one long-lived link. Trap: A signed URL with a long expiry is a shareable link to your course for as long as it lasts, and once it is in a group chat you cannot revoke it. Keep expiry short, minutes rather than days, and let the player request a fresh URL when it needs one. Done when: A video URL taken from the page and opened in a private window works within the expiry and fails after it. The object store returns a refusal for an unsigned request. A user without an entitlement receives no URL at all rather than a URL that fails later. Playback continues across the expiry boundary because the player re-signs. Do not build yet: No email sequences yet. ### Phase 5 · Email sequences from real events Build: Sequences of steps with delays, attached to a product. A purchase enqueues the sequence into an outbox. A scheduled command sends due messages, records failures with their error, and retries with backoff up to a cap. Unsubscribe must work. Trap: Sending during the webhook request couples your email provider's availability to Stripe's delivery timeout, and a slow provider turns into a failed webhook and a missing entitlement. Enqueue in the webhook and send from a scheduled job, always. Done when: A purchase enqueues the sequence with correct due times. Running the send command twice sends each message once. A permanently failing address is marked failed after the attempt cap rather than retried forever. An unsubscribe stops further sequence messages but not transactional ones such as a receipt. Do not build yet: Launch hardening is the last phase. ### Phase 6 · Launch, refunds and the restore drill Build: Deploy behind TLS with debug off. Handle refunds and disputes by revoking entitlements from the webhook. Back up the database and the video store off the machine, and restore both onto a clean server. Trap: A refund that leaves the entitlement in place means you have given the course away, and it is easy to miss because the refund path is rarely tested. Handle the refund and dispute events explicitly and test them with the Stripe command line tool before launch, not after the first one. Done when: A refund event revokes the entitlement and the student loses access. A dispute event does the same. The site serves over HTTPS with debug off. A restore onto a clean server reproduces courses, purchases and entitlements, video plays, and the date is recorded. Do not build yet: Nothing after this. ### Out of scope (and why) - The all-in-one convenience. You are now running a website, a payment integration, a video store and an email sender, and each has its own failure mode and its own bill. - The funnel builder and landing page editor, which is a visual tool and a large product in its own right. - Marketing automation with branching, scoring and segmentation. This build sends a linear sequence from a purchase event and stops there. - Mobile applications and offline lesson downloads, which students of paid courses increasingly expect. - The affiliate system and the analytics that come with a mature platform. ### README must contain - That entitlements are the single gate and that every access decision goes through one function - That the Stripe webhook is the only writer of entitlements, including for refunds and disputes - That video URLs are short-lived and signed only after the entitlement check - That sequence email is enqueued in the webhook and sent from a scheduled job - The backup schedule for database and video, and the date of the last restore
# Architecture · Kajabi ## Stack | Part | Choice | Why | | --- | --- | --- | | Framework | Django 5 with Python 3.12 | authentication, an admin for your content, migrations and a security posture on day one | | Interface | Django templates with HTMX | course pages are documents, and a frontend framework buys nothing here | | Database | PostgreSQL | purchases and access grants are money-adjacent and want a real database | | Payments | Stripe Checkout with webhooks | hosted checkout keeps card handling out of your scope entirely | | Video | An object store with signed, expiring URLs | the difference between gated video and video anyone can copy the link to | | Email | A transactional provider driven by an outbox table | sequences fire from purchase events, and a failed send must be visible rather than lost | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Entitlements | Every access decision. | One function, one table. If a second place ever decides access, that is the bug, regardless of what else changes. | | Payments | Checkout, webhooks and the subscription lifecycle. | Another processor changes the session call and the signature check. The rule that only the webhook writes entitlements survives. | | Video | Private storage and short-lived signed URLs. | Any S3-compatible store, or a video platform with signed playback. Short expiry with re-signing is the property that matters. | | Sequences | Scheduled email from purchase events. | A marketing tool could take this over, at the cost of another subscription and another integration. | | Outbox | Deferred sending with retries and visible failure. | A queue is the upgrade. The visible failed state is not optional. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DJANGO_SECRET_KEY` · required, secret · Generated once. Changing it invalidates every session and signed value, including unsubscribe links. - `DJANGO_DEBUG` · required · Zero everywhere but your own machine. - `DATABASE_URL` · required, secret · Your PostgreSQL connection string. - `STRIPE_SECRET_KEY` · required, secret · Stripe dashboard. Stay in test mode until the refund path has been tested. - `STRIPE_WEBHOOK_SECRET` · required, secret · From the registered endpoint or the listen command. - `VIDEO_BUCKET` · required · The private bucket holding lesson video. - `VIDEO_URL_EXPIRY_SECONDS` · required · How long a signed video URL lasts. Short, because a long one is a shareable link to your course. - `EMAIL_URL` · required, secret · Transactional provider credentials. ## 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 · Kajabi product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Django 5 with Python 3.12, Django templates with HTMX, PostgreSQL, Stripe Checkout with webhooks, An object store with signed, expiring URLs, A transactional provider driven by an outbox table. - 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 - Django's password reset silently does nothing useful with the console email backend, which looks like success in development. Configure a real transactional provider now, because everything in Phase 5 depends on it and domain verification takes time. - Scattering access checks across views and templates guarantees one of them is wrong, and the wrong one is the page that leaks. One function, called everywhere, with a test that enumerates every gated URL and asserts a refusal for a user without an entitlement. - Granting on the success redirect is the classic mistake, and here it hands out course access to anyone who can replay a URL. The webhook is the only writer of entitlements, and the redirect is a thank-you page that may arrive before the entitlement exists, so it must handle that gracefully. - A signed URL with a long expiry is a shareable link to your course for as long as it lasts, and once it is in a group chat you cannot revoke it. Keep expiry short, minutes rather than days, and let the player request a fresh URL when it needs one. - Sending during the webhook request couples your email provider's availability to Stripe's delivery timeout, and a slow provider turns into a failed webhook and a missing entitlement. Enqueue in the webhook and send from a scheduled job, always. - A refund that leaves the entitlement in place means you have given the course away, and it is easy to miss because the refund path is rarely tested. Handle the refund and dispute events explicitly and test them with the Stripe command line tool before launch, not after the first one.
# Delivery milestones · Kajabi Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Accounts, content and the admin You can author a course and a student can create an account, before any money moves. ### Steps 1. Create the project with PostgreSQL and real email Settings from environment variables, a transactional provider configured, and a reset email sent to yourself to prove it. Files: `config/settings.py` ```sh pip install django psycopg[binary] python-dotenv django-admin startproject config . ``` 2. Model courses, modules and lessons Ordered with explicit position fields rather than relying on insertion order. Files: `app/models.py` ```sh python manage.py makemigrations && python manage.py migrate ``` 3. Author one real course Not lorem ipsum. Real lessons expose ordering, length and navigation problems that placeholder content hides. ### Done when - [ ] Signup, logout and login all work - [ ] A password reset email reaches a real inbox - [ ] A course renders with modules and lessons in the right order - [ ] The admin refuses anonymous access and is not at the default path ### Watch out - Django's password reset silently does nothing useful with the console email backend, which looks like success in development. Configure a real transactional provider now, because everything in Phase 5 depends on it and domain verification takes time. ## M2 · Entitlements as the single gate Every access decision consults one table, so there is one place to be right. ### Steps 1. Write the single access function User plus product to a boolean, with the reason. Nothing else decides access anywhere in the codebase. Files: `app/access.py` 2. Gate every view through it Views, not templates. A hidden link is not a gate. Files: `app/views.py` 3. Enumerate gated URLs in a test Walk the URL configuration, request every gated route as an entitled and unentitled user, assert both outcomes. Files: `app/tests/test_gates.py` ### Done when - [ ] Every gated URL is refused without an entitlement, proved by an enumerating test - [ ] An entitlement opens exactly its own product's lessons - [ ] Revoking closes access immediately - [ ] No template performs an access decision ### Watch out - Scattering access checks across views and templates guarantees one of them is wrong, and the wrong one is the page that leaks. One function, called everywhere, with a test that enumerates every gated URL and asserts a refusal for a user without an entitlement. ## M3 · Payments that grant access Paying grants an entitlement, and only the webhook believes a payment happened. ### Steps 1. Create Checkout sessions server side Prices from your database, never from the client. Attach the user and product as metadata. Files: `app/payments.py` ```sh pip install stripe ``` 2. Make the webhook the only writer Signature verification, a processed-events table, and entitlement writes. Exempt from CSRF because the signature is the authentication. Files: `app/webhooks.py` 3. Handle the subscription lifecycle Period end for cancellation, terminal state for failure. A scheduled job revokes at period end. Files: `app/subscriptions.py` ### Done when - [ ] Access is granted only after the webhook - [ ] A replayed event leaves one entitlement - [ ] A cancelled subscription keeps access until the period end - [ ] A retrying failed payment does not revoke access ### Watch out - Granting on the success redirect is the classic mistake, and here it hands out course access to anyone who can replay a URL. The webhook is the only writer of entitlements, and the redirect is a thank-you page that may arrive before the entitlement exists, so it must handle that gracefully. ## M4 · Video that is actually gated A paying student can watch, and a non-paying visitor cannot simply copy the link. ### Steps 1. Lock the bucket down No public read. Test with an unsigned request and confirm a refusal before relying on signing. 2. Sign after checking entitlement The entitlement check happens before a URL exists. An unentitled user gets nothing to try. Files: `app/video.py` 3. Re-sign during playback The player requests a fresh URL before expiry so a long lesson does not stop mid-sentence. Files: `static/player.js` ### Done when - [ ] An unsigned request to the object store is refused - [ ] A copied URL stops working after the expiry - [ ] An unentitled user receives no URL at all - [ ] Playback continues across an expiry boundary ### Watch out - A signed URL with a long expiry is a shareable link to your course for as long as it lasts, and once it is in a group chat you cannot revoke it. Keep expiry short, minutes rather than days, and let the player request a fresh URL when it needs one. ## M5 · Email sequences from real events Buying triggers a sequence, and a failed send is a row you can see rather than a message that vanished. ### Steps 1. Model sequences and steps Delays relative to the trigger event, so a three day step means three days after purchase rather than after the previous send. Files: `app/sequences.py` 2. Enqueue in the webhook, send in a job The webhook writes rows and returns. A scheduled command does the sending. Files: `app/management/commands/send_outbox.py` ```sh python manage.py send_outbox ``` 3. Implement unsubscribe properly A signed link, honoured for marketing sequences and not for receipts. Test both cases. Files: `app/unsubscribe.py` ### Done when - [ ] A purchase enqueues the sequence with correct due times - [ ] Running the send command twice sends each message once - [ ] A failing address is capped rather than retried forever - [ ] Unsubscribe stops sequence email and not receipts ### Watch out - Sending during the webhook request couples your email provider's availability to Stripe's delivery timeout, and a slow provider turns into a failed webhook and a missing entitlement. Enqueue in the webhook and send from a scheduled job, always. ## M6 · Launch, refunds and the restore drill (production only) It runs in production, handles a refund correctly, and you have restored it. ### Steps 1. Handle refunds and disputes Explicit webhook handlers that revoke. Trigger both with the Stripe command line tool and verify access is lost. Files: `app/webhooks.py` ```sh stripe trigger charge.refunded ``` 2. Deploy properly TLS, debug off, allowed hosts set, static files served by the proxy, secrets from the environment. Files: `Caddyfile` ```sh python manage.py collectstatic ``` 3. Back up both and restore Database and video store, dated, off the machine. Restore onto a clean server, play a video, record the date. Files: `README.md` ### Done when - [ ] A refund revokes the entitlement and access is lost - [ ] A dispute does the same - [ ] The site serves over HTTPS with debug off - [ ] A restore reproduces access and playable video, and is dated ### Watch out - A refund that leaves the entitlement in place means you have given the course away, and it is easy to miss because the refund path is rarely tested. Handle the refund and dispute events explicitly and test them with the Stripe command line tool before launch, not after the first one.
# Operations · Kajabi ## Backup Nightly database dump plus the video store, both off the machine. Video is large, so a separate schedule is reasonable, but a database restore without video gives you a site of broken lessons. ## Restore Restore the database, confirm the video store is reachable, sign a URL and play a lesson. That last step is the one that proves the restore, because it exercises both halves. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Webhook failures, outbox rows older than an hour, and video egress. A rising outbox age means email broke, which you will otherwise learn from a student. ## Incident checklist For a missed refund, the entitlement is still active and a student has free access; check the webhook handler first. For a video that will not play, check the clock skew on the signing machine before anything else, since signed URLs are time sensitive. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] The enumerating gate test passes for every gated URL - [ ] A replayed Stripe event does not double-grant - [ ] A refund is confirmed to revoke access - [ ] A copied video URL is confirmed to expire - [ ] A restore has been performed and a lesson video played from it ## Launch constraint Do not market omitted Kajabi 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. Generated once. Changing it invalidates every session and signed value, including unsubscribe links. DJANGO_SECRET_KEY=replace-with-50-random-characters # Required. Zero everywhere but your own machine. DJANGO_DEBUG=0 # Required · secret. Your PostgreSQL connection string. DATABASE_URL=postgres://user:pass@localhost:5432/courses # Required · secret. Stripe dashboard. Stay in test mode until the refund path has been tested. STRIPE_SECRET_KEY=sk_test_... # Required · secret. From the registered endpoint or the listen command. STRIPE_WEBHOOK_SECRET=whsec_... # Required. The private bucket holding lesson video. VIDEO_BUCKET=courses-video # Required. How long a signed video URL lasts. Short, because a long one is a shareable link to your course. VIDEO_URL_EXPIRY_SECONDS=300 # Required · secret. Transactional provider credentials. EMAIL_URL=smtp://user:pass@smtp.provider.com:587
$ choose a build depth, inspect the files, then open the complete pack in your agent
They pay to avoid stitching and maintaining a dozen creator-business tools.
xall-in-one stack
xfunnels
xemail automations
xcommunities
xcourse delivery
xanalytics
xcustomer support
Don't feel like building it? These folks already made it free.
no votes, no pay-to-list · just what's real
Kajabi pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| basic | $179/workspace | $143/workspace | 5 products; 2,500 contacts; 1 website; 1 community; 2 admin users; 0 included AI credits. |
| growth | $249/workspace | $199/workspace | 50 products; 25,000 contacts; 1 website; 1 community; 11 admin users; 500 AI credits. |
| pro | $499/workspace | $399/workspace | Unlimited products; 100,000 contacts; 3 websites; 3 communities; 26 admin users; 1,000 AI credits. |
free tierno free tier; 14-day trial
billingmonthly + annual (-20%)
hidden costsKajabi Payments rates are 2.9% + $0.30 / 2.8% + $0.30 / 2.7% + $0.30; third-party gateways add 2% / 1% / 0.5%. Basic/Growth pay $199/month for a branded app and $25/month for public API access; 500 AI-credit top-ups are $15; extra Backstage seats are listed at $20/seat/month on the detailed help page.
verified 2026-08-14 · source ↗
Vibecode Kajabi
Kinda. The core of Kajabi is buildable in a weekend with the prompt on this page, but there are real gaps: all-in-one stack, funnels. Read the honest list above before committing.
How much does Kajabi cost?
Kajabi costs about $179/month (Basic, checked 2026-07-30), which is $2148 per year.
What do I lose by replacing Kajabi?
Honestly: all-in-one stack; funnels; email automations; communities; course delivery; analytics; customer support. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Kajabi?
Yes: CourseLit (Courses, downloads, pages, email automation and paid communities in one self-hosted product; bookings remain elsewhere.) systeme.io (One course, three funnels, email, a community and 2,000 contacts for free; enough for a small business, not a growing empire.) The prompt is for when you want it exactly your way.