Stripe Checkout
James looked at the upgrade prompt in WhatsApp. "Upgrade to unlock all chapters." Below it: a placeholder URL that went nowhere.
"The tier gating works," he said. "Free learners get blocked at chapter six. The upgrade message appears. But the link is a dead end."
Emma pulled up the Stripe dashboard on her screen. "Stripe. You create a checkout page on their servers. They handle the card, the receipt, the PCI compliance. You get a webhook when the payment clears."
James thought for a moment. "Like a cash register. I ring up the item, the point-of-sale terminal handles the card, and the receipt updates the inventory."
"Exactly. Your server creates the session. Stripe handles the money. Their webhook tells your server the payment went through. You update the JSON. Three parties, one flow."
You are doing exactly what James is doing. Your tier gating works from Lesson 13, but the upgrade link is still a mock from Lesson 6. Time to replace it with a real Stripe Checkout session and a webhook that upgrades the tier when payment completes.
Three steps. First, you set up Stripe yourself (this is product work, not code). Second, you describe the integration to Claude Code. Third, you verify the full flow end-to-end with a test card payment.
Set Up Stripe (You, Not Claude Code)
Stripe account setup is a business decision. You choose the product name, the price, the currency. Claude Code does not make these decisions for you.
Step 1: Create a Stripe Account
Go to dashboard.stripe.com/register and create a free account. No credit card required for test mode.
Step 2: Enable Test Mode
In the Stripe Dashboard, toggle Test mode in the top-right corner. Every action you take in test mode uses fake money and fake cards. No real charges.
Step 3: Create a Product
Navigate to Product catalog and create a new product:
| Field | Value |
|---|---|
| Name | TutorClaw Paid Tier |
| Price | 9.99 (or whatever you choose) |
| Payment type | One-time |
| Currency | Your local currency |
After saving, find the Price ID on the product page. It starts with price_. Copy it.
Step 4: Get Your API Keys
Navigate to Developers then API keys. You need two keys:
| Key | Starts With | Purpose |
|---|---|---|
| Secret key | sk_test_ | Your server uses this to create Checkout sessions |
| Publishable key | pk_test_ | Not needed for this lesson (used for client-side Stripe.js) |
Copy the secret key. You will add it to your environment variables.
Step 5: Install the Stripe CLI
The Stripe CLI forwards webhooks to your local server during development. Install it following the instructions at docs.stripe.com/stripe-cli.
After installing, authenticate with your Stripe account:
stripe login
Follow the browser prompt to link the CLI to your test-mode account.
Step 6: Set Environment Variables
Your TutorClaw server needs three environment variables. Add them to a .env file in your project root (or set them in your terminal session):
| Variable | Value | Source |
|---|---|---|
STRIPE_SECRET_KEY | Your sk_test_ key | Stripe Dashboard, API keys page |
STRIPE_PRICE_ID_PAID | Your price_ ID | Stripe Dashboard, product page |
STRIPE_WEBHOOK_SECRET | Set this after Step 7 below | Stripe CLI output |
Add .env to your .gitignore if it is not already there. The sk_test_ key gives full access to your Stripe test account. Committing it to git is a security mistake even in test mode, because the habit carries over to production keys.
Describe the Integration to Claude Code
With Stripe set up, the coding work is a describe-steer-verify cycle. You tell Claude Code what changed about get_upgrade_url, and you describe a new webhook endpoint.
Describe the Updated get_upgrade_url
Open Claude Code in your tutorclaw-mcp project and describe the change:
Update get_upgrade_url to create a real Stripe Checkout session
instead of returning a mock URL. Use the stripe Python library.
The tool takes a learner_id. It reads the learner's tier from JSON
state. If the learner is already paid, return an error.
If the learner is on the free tier, create a Stripe Checkout session
with the price ID from the STRIPE_PRICE_ID_PAID environment variable.
Include the learner_id in the session metadata so the webhook handler
knows which learner paid. Return the checkout session URL.
The Stripe secret key comes from the STRIPE_SECRET_KEY environment
variable. Never hardcode keys in the source code.
Notice what you described: the behavior (create a checkout session), the data flow (learner_id in metadata), the security constraint (keys from environment variables), and the scope (replace the mock, keep everything else). You did not describe the Stripe API syntax. Claude Code handles that.
Review and Steer
When Claude Code returns the spec, check:
| Check | What to Look For |
|---|---|
| Stripe library | Uses the stripe Python package (not raw HTTP calls) |
| Environment variables | Reads keys from environment, not hardcoded strings |
| Metadata | Includes learner_id in the checkout session metadata |
| Success and cancel URLs | The session has both (Stripe requires them). Localhost URLs are fine for testing. |
| Error handling | Returns a clear error if the learner is already paid or if the Stripe API call fails |
If anything looks off, steer it:
Make sure the learner_id is stored in the session metadata, not
just the client_reference_id. The webhook handler will read it from
metadata to find the learner in JSON state.
Once the spec looks right, approve the build.
Describe the Webhook Handler
The webhook is a separate endpoint on your server. When Stripe processes a payment, it sends an HTTP POST to this endpoint with the event details. Describe it to Claude Code:
Add a webhook endpoint at /webhook that handles Stripe events.
When a checkout.session.completed event arrives:
1. Verify the webhook signature using the STRIPE_WEBHOOK_SECRET
environment variable
2. Extract the learner_id from the session metadata
3. Read the learner's record from JSON state
4. Change the learner's tier from "free" to "paid"
5. Save the updated JSON state
Return a 200 status for valid events. Return a 400 for invalid
signatures. Ignore event types other than checkout.session.completed.
Review the Webhook Spec
Check:
| Check | What to Look For |
|---|---|
| Signature verification | Uses the Stripe library's webhook signature verification, not a custom check |
| Event filtering | Only processes checkout.session.completed, ignores other events |
| learner_id extraction | Reads from session metadata (matching what get_upgrade_url stores) |
| JSON update | Reads the current file, updates the tier, writes it back |
| Response codes | 200 for success, 400 for bad signature |
Approve the build when the spec looks right.
Test the Full Flow
Testing a Stripe integration locally requires the Stripe CLI to forward webhook events to your server. This is the standard local development workflow for any webhook-based integration.
Step 7: Start the Stripe CLI Listener
In a separate terminal, start the webhook forwarding:
stripe listen --forward-to localhost:8000/webhook
The CLI prints a webhook signing secret that starts with whsec_. Copy this value and set it as your STRIPE_WEBHOOK_SECRET environment variable. This secret is different from your API key. The API key authenticates your server's requests to Stripe. The webhook secret verifies that incoming webhook requests actually came from Stripe.
Step 8: Start Your TutorClaw Server
In another terminal, start the server with the environment variables loaded:
uv run tutorclaw
Confirm the server starts without errors.
Step 9: Call get_upgrade_url
Use Claude Code or a direct HTTP call to invoke get_upgrade_url for a free-tier learner. The tool should return a URL that starts with https://checkout.stripe.com/.
Open that URL in your browser. You should see a Stripe Checkout page with your product name and price.
Step 10: Pay With the Test Card
Stripe provides test card numbers that simulate successful payments. Enter:
| Field | Value |
|---|---|
| Card number | 4242 4242 4242 4242 |
| Expiry | Any future date |
| CVC | Any 3 digits |
| Name | Any name |
Click Pay. Stripe processes the test payment instantly.
Step 11: Watch the Webhook
Switch to the terminal running stripe listen. You should see output showing the events Stripe sent, including checkout.session.completed.
The Stripe CLI forwarded that event to your server's /webhook endpoint. Your handler verified the signature, extracted the learner_id from the metadata, and updated the JSON state.
Step 12: Verify the Tier Change
Open your learner's JSON state file (in the data/ directory). The learner's tier should now be "paid" instead of "free".
This is the complete monetization flow: learner hits a paywall, gets a checkout URL, pays with a card, webhook fires, tier upgrades, content unlocks. One payment, one webhook, one JSON update.
Verify Existing Tests Still Pass
Run the test suite to make sure nothing broke:
uv run pytest
The existing tests should still pass. The mock behavior for already-paid learners is the same (return an error). The difference is that free-tier learners now get a real Stripe URL instead of a placeholder.
If you get confused about which key is which, remember the direction of traffic. The API secret key (sk_test_) authenticates requests going OUT from your server to Stripe. The webhook signing secret (whsec_) verifies requests coming IN from Stripe to your server. Outbound: API key. Inbound: webhook secret.
The Three-Party Flow
Step back and look at what you built. Three parties are involved in every payment:
| Party | Role |
|---|---|
| Your server | Creates the checkout session, handles the webhook, updates the tier |
| Stripe | Hosts the payment page, processes the card, sends the webhook |
| The learner | Clicks the link, enters card details, completes payment |
Your server never sees a credit card number. Stripe's hosted Checkout page handles all of that. Your server only needs to create the session (outbound API call) and handle the result (inbound webhook).
The webhook is the critical piece. The checkout URL alone does not confirm payment: the learner might close the browser, the redirect might fail, network errors happen. The webhook is Stripe's server-to-server confirmation that money moved. Your handler trusts it (after verifying the signature) and updates the tier.
Try With AI
Exercise 1: Test a Failed Payment
Stripe provides test card numbers that simulate failures. Ask Claude Code about them:
What Stripe test card numbers simulate a declined payment? I want to
test what happens when a learner's card is declined during checkout.
What event does Stripe send in that case, and should my webhook
handler do anything with it?
What you are learning: A payment integration is not complete until you know how failures behave. Successful payments are the happy path. Declined cards, expired cards, and insufficient funds are the realistic path.
Exercise 2: Inspect the Webhook Payload
Ask Claude Code to add logging so you can see what Stripe sends:
Add logging to the webhook handler that prints the full event type
and the session metadata when a checkout.session.completed event
arrives. I want to see exactly what Stripe sends so I can verify the
learner_id is in the metadata.
Run another test payment after adding the logging. Check the server output.
What you are learning: Webhook payloads are your debugging tool for payment integrations. When something goes wrong (tier not updating, wrong learner upgraded), the payload tells you exactly what Stripe sent and what your handler processed.
Exercise 3: Handle Double Payments
What happens if Stripe sends the same webhook event twice? (This can happen during network retries.) Ask Claude Code:
What happens if the webhook handler receives a checkout.session.completed
event for a learner who is already on the paid tier? Is the current
handler idempotent? If not, what should it do differently?
What you are learning: Webhook handlers must be idempotent: processing the same event twice should produce the same result as processing it once. Stripe guarantees at-least-once delivery, not exactly-once. Your handler must handle duplicates gracefully.
James watched the terminal. The stripe listen output showed checkout.session.completed. He opened the JSON file. The tier field read "paid".
"One payment. One webhook. One JSON update." He closed the file. "The product makes money."
Emma nodded. "That is the entire monetization flow. A learner hits the paywall, gets a URL, pays, and unlocks everything. Three parties, three steps, done."
"It felt simpler than I expected."
"Payment integration is simple when you let the payment provider handle the hard part. Card validation, receipt generation, PCI compliance, refund handling: all Stripe's problem." She paused. "I once deployed a Stripe integration with test keys still set in production. Eight hours before anyone noticed. No real charges were processed, but the embarrassment was real. The fix was one environment variable, but the incident review was four hours long."
"How do you prevent that?"
"Environment variable checklist before every deploy. And a staging environment that uses test keys deliberately, so production is the only place live keys exist." She pointed at his screen. "Your tier changes. Your tests pass. Next lesson: test the full payment flow end-to-end from WhatsApp. A free learner hits the paywall, pays, and immediately gets access to paid content without restarting anything."