Build Kajabi
KINDAreplaces $179/mosaves $2,148/yrback to the verdict
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.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- installfree
Why Runs Django.
Get it python.org or your package manager, then a virtual environment.
Verify
python3 --version prints 3.12 or higher - 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 - 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 - 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 - 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 - 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.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DJANGO_SECRET_KEYsecret | required | replace-with-50-random-characters | Generated once. Changing it invalidates every session and signed value, including unsubscribe links. |
DJANGO_DEBUG | required | 0 | Zero everywhere but your own machine. |
DATABASE_URLsecret | required | postgres://user:pass@localhost:5432/courses | Your PostgreSQL connection string. |
STRIPE_SECRET_KEYsecret | required | sk_test_... | Stripe dashboard. Stay in test mode until the refund path has been tested. |
STRIPE_WEBHOOK_SECRETsecret | required | whsec_... | From the registered endpoint or the listen command. |
VIDEO_BUCKET | required | courses-video | The private bucket holding lesson video. |
VIDEO_URL_EXPIRY_SECONDS | required | 300 | How long a signed video URL lasts. Short, because a long one is a shareable link to your course. |
EMAIL_URLsecret | required | smtp://user:pass@smtp.provider.com:587 | Transactional provider credentials. |
The build, in order
Accounts, content and the admin
You can author a course and a student can create an account, before any money moves.
Settings from environment variables, a transactional provider configured, and a reset email sent to yourself to prove it.
Files
config/settings.pyterminalpip install django psycopg[binary] python-dotenv django-admin startproject config .
Ordered with explicit position fields rather than relying on insertion order.
Files
app/models.pyterminalpython manage.py makemigrations && python manage.py migrate
Not lorem ipsum. Real lessons expose ordering, length and navigation problems that placeholder content hides.
done when · tick each as it passeswatch 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.
Entitlements as the single gate
Every access decision consults one table, so there is one place to be right.
User plus product to a boolean, with the reason. Nothing else decides access anywhere in the codebase.
Files
app/access.pyViews, not templates. A hidden link is not a gate.
Files
app/views.pyWalk 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 passeswatch 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.
Payments that grant access
Paying grants an entitlement, and only the webhook believes a payment happened.
Prices from your database, never from the client. Attach the user and product as metadata.
Files
app/payments.pyterminalpip install stripe
Signature verification, a processed-events table, and entitlement writes. Exempt from CSRF because the signature is the authentication.
Files
app/webhooks.pyPeriod end for cancellation, terminal state for failure. A scheduled job revokes at period end.
Files
app/subscriptions.py
done when · tick each as it passeswatch 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.
Video that is actually gated
A paying student can watch, and a non-paying visitor cannot simply copy the link.
No public read. Test with an unsigned request and confirm a refusal before relying on signing.
The entitlement check happens before a URL exists. An unentitled user gets nothing to try.
Files
app/video.pyThe 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 passeswatch 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.
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.
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.pyThe webhook writes rows and returns. A scheduled command does the sending.
Files
app/management/commands/send_outbox.pyterminalpython manage.py send_outbox
A signed link, honoured for marketing sequences and not for receipts. Test both cases.
Files
app/unsubscribe.py
done when · tick each as it passeswatch 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.
Launch, refunds and the restore drillproduct builder
It runs in production, handles a refund correctly, and you have restored it.
Explicit webhook handlers that revoke. Trigger both with the Stripe command line tool and verify access is lost.
Files
app/webhooks.pyterminalstripe trigger charge.refunded
TLS, debug off, allowed hosts set, static files served by the proxy, secrets from the environment.
Files
Caddyfileterminalpython manage.py collectstatic
Database and video store, dated, off the machine. Restore onto a clean server, play a video, record the date.
Files
README.md
done when · tick each as it passeswatch 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.
That is the whole plan for Kajabi. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- 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
- 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
Need the files? The project pack on the verdict page hands your agent the whole brief · more creator commerce.