Build Kajabi

KINDAreplaces $179/mosaves $2,148/yrback to the verdict

0%0 of 21 items done

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

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

FrameworkDjango 5 with Python 3.12InterfaceDjango templates with HTMXDatabasePostgreSQLPaymentsStripe Checkout with webhooksVideoAn object store with signed, expiring URLsEmailA transactional provider driven by an outbox table

Before step 1

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

  1. installfree

    Why Runs Django.

    Get it python.org or your package manager, then a virtual environment.

    Verify python3 --version prints 3.12 or higher

  2. installfree

    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. open ↗

    Verify psql connects and reports 16 or higher

  3. API keyfree 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. open ↗

    Verify A triggered test event reaches your endpoint and is verified

  4. API keystorage 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

  5. API keyfree 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

  6. have readyfree

    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

Data model

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

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

Environment variables

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

VariableNeededExampleWhere the value comes from
DJANGO_SECRET_KEYsecretrequiredreplace-with-50-random-charactersGenerated once. Changing it invalidates every session and signed value, including unsubscribe links.
DJANGO_DEBUGrequired0Zero everywhere but your own machine.
DATABASE_URLsecretrequiredpostgres://user:pass@localhost:5432/coursesYour PostgreSQL connection string.
STRIPE_SECRET_KEYsecretrequiredsk_test_...Stripe dashboard. Stay in test mode until the refund path has been tested.
STRIPE_WEBHOOK_SECRETsecretrequiredwhsec_...From the registered endpoint or the listen command.
VIDEO_BUCKETrequiredcourses-videoThe private bucket holding lesson video.
VIDEO_URL_EXPIRY_SECONDSrequired300How long a signed video URL lasts. Short, because a long one is a shareable link to your course.
EMAIL_URLsecretrequiredsmtp://user:pass@smtp.provider.com:587Transactional provider credentials.

The build, in order

  1. Accounts, content and the admin

    You can author a course and a student can create an account, before any money moves.

    1. Settings from environment variables, a transactional provider configured, and a reset email sent to yourself to prove it.

      Files config/settings.py

      terminal
      pip install django psycopg[binary] python-dotenv
      django-admin startproject config .
    2. Ordered with explicit position fields rather than relying on insertion order.

      Files app/models.py

      terminal
      python manage.py makemigrations && python manage.py migrate
    3. Not lorem ipsum. Real lessons expose ordering, length and navigation problems that placeholder content hides.

    done when · tick each as it passes
    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.
  2. Entitlements as the single gate

    Every access decision consults one table, so there is one place to be right.

    1. User plus product to a boolean, with the reason. Nothing else decides access anywhere in the codebase.

      Files app/access.py

    2. Views, not templates. A hidden link is not a gate.

      Files app/views.py

    3. 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 · tick each as it passes
    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.
  3. Payments that grant access

    Paying grants an entitlement, and only the webhook believes a payment happened.

    1. Prices from your database, never from the client. Attach the user and product as metadata.

      Files app/payments.py

      terminal
      pip install stripe
    2. Signature verification, a processed-events table, and entitlement writes. Exempt from CSRF because the signature is the authentication.

      Files app/webhooks.py

    3. Period end for cancellation, terminal state for failure. A scheduled job revokes at period end.

      Files app/subscriptions.py

    done when · tick each as it passes
    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.
  4. Video that is actually gated

    A paying student can watch, and a non-paying visitor cannot simply copy the link.

    1. No public read. Test with an unsigned request and confirm a refusal before relying on signing.

    2. The entitlement check happens before a URL exists. An unentitled user gets nothing to try.

      Files app/video.py

    3. The player requests a fresh URL before expiry so a long lesson does not stop mid-sentence.

      Files static/player.js

    done when · tick each as it passes
    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.
  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.

    1. 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. The webhook writes rows and returns. A scheduled command does the sending.

      Files app/management/commands/send_outbox.py

      terminal
      python manage.py send_outbox
    3. A signed link, honoured for marketing sequences and not for receipts. Test both cases.

      Files app/unsubscribe.py

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

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