Receive real money without Shopify.
This is the actual code running Side Quest's shop page. No monthly store fee, no template fighting your design. Just a button, a server function, and Stripe doing the one part you should never build yourself.
The last two tutorials taught you to design a logo and build a brand. This one covers the part most guides skip because it sounds scary. How does a click on a website turn into money in a real bank account, without you ever touching a credit card number?
💡 What you need: the site from Tutorial 03, a free Stripe account, and everything else you already have. A text editor and a browser.
Two Rules First
Before a single line of code, two rules that never bend, no matter which platform you use. Shopify, a custom site, anything.
⚠️ Rule 1. You never touch a real card number. Not as text in a form, not saved in a database, not logged anywhere. Storing card data yourself is a legal requirement called PCI compliance, and the realistic way to meet it as one person is simple. Don't. Let a payment processor do it.
⚠️ Rule 2. Prices live on your server, never in the page the browser sees. If your buy button sends a price to the server, anyone can open their browser's dev tools and change $14.00 to $0.01 before clicking. The fix is that the button sends which item, and the server looks up what it costs. We'll build exactly that in Lesson 5.
Everything below exists to satisfy those two rules with the least amount of code possible.
The Buy Button
Start with the part a person sees. It's a plain button. Nothing about payment happens here. It says which item, and waits.
<button class="buy" data-item="pin"> Preorder — $14 </button>
data-item="pin" is the entire message this button sends. Not a price, not a name, just an id. That id is a key we'll look up on the server in Lesson 5. Keeping the button this plain is what makes Rule 2 possible.
Try it: add a second button with data-item="stickers". You don't need to touch anything else yet. It won't do anything until Lesson 3, and that's expected.
A Server You Don't See
Everything so far has been a file sitting on a server, sent to whoever asks for it. That's what a "static site" means, and it's all you needed for Tutorial 03. But receiving money needs something that can think. Check a price, talk to Stripe, decide what to send back. That needs actual code running on a server, not just a file.
If you're hosting on Cloudflare Pages, you already have this. It's called a Function, and the rule is almost too simple. Any file you put in a folder named functions/ becomes a tiny bit of server, automatically, for free.
site/
index.html
shop/
index.html
functions/
api/
checkout.js ← becomes /api/checkout
That's the whole setup step. No server to rent, no separate service to sign up for. It deploys alongside the rest of your site because it is part of your site.
💡 A file's path becomes its web address. functions/api/checkout.js is reachable at yoursite.com/api/checkout. No routing config to write.
Talking to Stripe
Here's the actual flow, before the code. The button gets clicked. Your page asks your own function for a pin. Your function asks Stripe to build this person a checkout page. Stripe hands back a URL. Your page sends the browser there. The customer types their card number on a page that Stripe built and Stripe owns. It never touches yours.
Preorder
asks Stripe
a checkout page
pays there
// runs when the browser calls /api/checkout export async function onRequestPost(context) { const { request, env } = context; const body = await request.json(); // ask Stripe to create a checkout page const res = await fetch( "https://api.stripe.com/v1/checkout/sessions", { method: "POST", headers: { Authorization: `Bearer ${env.STRIPE_SECRET_KEY}` }, body: /* what to sell — Lesson 5 */ } ); const session = await res.json(); return new Response( JSON.stringify({ url: session.url }) ); }
env.STRIPE_SECRET_KEY is your Stripe password, effectively. It never appears in this file as actual text. You set it separately in the Cloudflare dashboard, so it's never something you'd accidentally publish. Cloudflare hands it to your function only while it's running.
⚠️ There are two kinds of Stripe key. One starts with pk_ and is public, fine to expose, meant for the browser. One starts with sk_ and is secret. That's the one above, and it must never appear in anything sent to a browser. Functions are exactly where a secret key belongs, because this code only ever runs on the server, never on a visitor's device.
Prices Live on the Server
Now the part that actually enforces Rule 2. Before your function talks to Stripe, it looks up what the clicked item costs, from a list it owns, not from anything the browser sent.
// the only place prices exist. not in the HTML, not in the browser. const CATALOG = { pin: { name: "Enamel Pin", amount: 1400 }, // cents stickers: { name: "Sticker Sheet", amount: 1300 }, tee: { name: "Tee", amount: 3000 } }; // body.item is just the id the button sent — "pin", "stickers", "tee" const item = CATALOG[body.item]; if (!item) { return new Response("Unknown item", { status: 400 }); } // item.amount is what actually gets charged — never anything from the browser
Walk through the attack this defeats. Someone opens dev tools, finds your fetch call, and tries resending it with {"item":"pin","price":1}. Doesn't matter. Your function never reads a price from that request. It only reads item, looks up CATALOG[item], and that's the number Stripe actually charges. The tampered field is simply ignored.
💰 This one pattern, never trust a number the browser sends you and always look it up yourself, is the single most important idea in this whole tutorial. It isn't special to payments. It's how you'd protect a game's high scores, a raffle entry count, anything where someone could gain by lying to your server.
Test Before Real
Stripe gives every account two parallel modes. Test and live. Test mode works identically to the real thing, same code and same checkout page, except no real money ever moves. You'll know which mode you're in because test keys start with sk_test_ and live ones start with sk_live_.
Build and break things in test mode using Stripe's official fake card:
Card number: 4242 4242 4242 4242
Expiry: any future date
CVC: any 3 digits
ZIP: any 5 digits
Click your button, land on Stripe's real checkout page, pay with that card, and land back on your site. Full round trip, zero risk. Do this until it works without surprises before you ever switch the key to sk_live_.
Try it: in the Stripe dashboard, toggle to test mode and look at the payment you just made. It shows up exactly like a real one would. Same dashboard, same data, just clearly labeled as a test.
Closing the Window
None of this exists only to receive money. It exists to run a preorder correctly. Collect orders, and only actually produce something if enough people wanted it.
The good news is that you don't need to build a counting system. Stripe's dashboard already lists every payment, filterable by what was bought. When your window closes:
- Open Stripe → Payments, filter by product
- Count the orders for each item
- Below your supplier's minimum? Go to the Refunds tab and refund those, one click each
- At or above? Place the bulk order, funded by money already sitting in your Stripe balance
💰 This is the part that makes a preorder safe to run as one person. You're never spending your own money to guess at demand. The customer's payment arrives first. Production only happens after you already know, for certain, how many to make.
💡 Want to see this exact system live? The Side Quest shop page runs on precisely the code in this tutorial. View its page source, or ask for the repository, and you're looking at a real working example, not a simplified stand-in.
Your Turn
Add a Fourth Item
Add one more entry to CATALOG and a matching button. That's the entire process for adding a product.
Break Your Own Security
Open dev tools, find the fetch call, try resending it with a fake price. Confirm it gets ignored. Understanding the attack is how you trust the defense.
Add a Quantity Limit
Change the function so no single order can request more than 5 of an item. Where in checkout.js would that check belong, before or after the price lookup?