How I Set Up Stripe Checkout in 2 Hours Without a Backend
I wanted to sell developer kits on a static site. No Express. No Next.js. No database for orders. Just HTML, three serverless functions, and Stripe doing 90% of the work. Here's the whole architecture.
The Problem
I had 9 digital products I wanted to sell on dashbuilds.dev/shop. Developer kits — downloadable ZIP files with templates, configs, and documentation. The kind of thing where a buyer clicks "Buy," pays, and immediately gets a download link.
Simple enough. But every tutorial I found assumed I was running a full backend. Express server with session management. Next.js with API routes and a database ORM. Rails with ActiveRecord. The whole production.
I didn't want any of that. My site is static HTML hosted on Vercel. No framework. No build step. I write <div> tags by hand like it's 2004, and I'm not ashamed of it. The idea of adding a Node server just to process payments felt like bringing a forklift to move a chair.
So I asked myself: what's the absolute minimum I need to accept payments and deliver files?
The answer turned out to be three files.
The Architecture: 3 Files, That's It
Here's the entire payment system. I'm going to show you a diagram first, then walk through each piece:
Customer clicks "Buy"
|
v
+-----------------+ Creates Stripe Checkout Session
| api/checkout.js | --> redirects customer to Stripe
+-----------------+
|
v
[Stripe Checkout] <-- Stripe handles the entire payment UI
|
|--- on success ---> /success?session_id=cs_xxx
| |
| v
| +----------------+
| | api/session.js | --> fetches session data
| +----------------+ returns download links
|
|--- webhook ------> +------------------+
| api/webhook.js | --> verifies signature
+------------------+ sends email via Resend
That's it. Three serverless functions in the /api directory. Vercel turns each one into its own Lambda. No Express, no framework, no package.json with 47 dependencies. Each file is under 80 lines.
Let me walk through each one.
checkout.js — Creating the Session
When a customer clicks a "Buy" button on my shop page, it sends a POST request to /api/checkout with the product slug. The function maps that slug to a Stripe price ID, creates a Checkout Session, and returns the URL.
Here's the important part: I'm not using the Stripe SDK. It's just fetch with form-encoded body params hitting the Stripe API directly. One less dependency. One less thing to break.
// api/checkout.js
export default async function handler(req, res) {
const { slug } = req.body;
// Map product slugs to Stripe price IDs
const prices = {
'launch-kit': 'price_1Qx...',
'api-blueprint': 'price_1Qy...',
'seo-starter': 'price_1Qz...',
// ... 9 products total
};
const priceId = prices[slug];
if (!priceId) return res.status(400).json({ error: 'Unknown product' });
// Create Checkout Session via raw fetch (no SDK)
const session = await fetch('https://api.stripe.com/v1/checkout/sessions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.STRIPE_SECRET_KEY}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
'mode': 'payment',
'line_items[0][price]': priceId,
'line_items[0][quantity]': '1',
'success_url': `https://dashbuilds.dev/success?session_id={CHECKOUT_SESSION_ID}`,
'cancel_url': `https://dashbuilds.dev/shop`,
'metadata[slug]': slug,
}),
}).then(r => r.json());
res.json({ url: session.url });
}
The client-side JavaScript is equally simple. The buy button calls this endpoint, gets a URL back, and does window.location.href = data.url. The customer lands on Stripe's hosted checkout page. Stripe handles the card form, validation, 3D Secure, Apple Pay, Google Pay — all of it. I didn't build any of that.
The stripe npm package is great, but it adds ~1MB to your serverless function bundle. For a single API call, raw fetch with URLSearchParams works fine. Stripe's REST API is well-documented and stable. The SDK is convenient for complex integrations — subscriptions, metered billing, Connect — but for one-time payments? Overkill.
One thing that tripped me up: the metadata[slug] parameter. You need to pass your own identifier into the session so you can figure out which product was purchased later (on the success page and in the webhook). Stripe metadata is the glue that holds this whole system together.
webhook.js — Signature Verification + Email
After a successful payment, Stripe fires a checkout.session.completed webhook to /api/webhook. This is where I verify the payment actually happened and send myself a notification email.
Signature verification is non-negotiable. Without it, anyone could POST fake data to your webhook endpoint and trick your system into thinking a payment occurred. Stripe signs every webhook payload with a secret, and you need to verify that signature before trusting the data.
// api/webhook.js
import { buffer } from 'micro';
import crypto from 'crypto';
export const config = { api: { bodyParser: false } };
function verifyStripeSignature(payload, sigHeader, secret) {
const parts = Object.fromEntries(
sigHeader.split(',').map(p => p.split('='))
);
const signed = `${parts.t}.${payload}`;
const expected = crypto
.createHmac('sha256', secret)
.update(signed)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(parts.v1)
);
}
export default async function handler(req, res) {
const buf = await buffer(req);
const sig = req.headers['stripe-signature'];
if (!verifyStripeSignature(buf.toString(), sig, process.env.STRIPE_WEBHOOK_SECRET)) {
return res.status(400).send('Invalid signature');
}
const event = JSON.parse(buf);
if (event.type === 'checkout.session.completed') {
const session = event.data.object;
// Send notification via Resend
await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.RESEND_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: 'Dash Builds <shop@dashbuilds.dev>',
to: 'dash@dashbuilds.dev',
subject: `New sale: ${session.metadata.slug}`,
html: `<p>${session.customer_details.email} purchased ${session.metadata.slug} for $${session.amount_total / 100}</p>`,
}),
});
}
res.json({ received: true });
}
A few things worth noting here:
- Raw body parsing. You need
bodyParser: falseand themicrobuffer to get the raw request body. Stripe signatures are computed over the raw payload, not the parsed JSON. If your framework parses the body first, the signature won't match. This is the #1 gotcha with Stripe webhooks. - Timing-safe comparison. Use
crypto.timingSafeEqual, not===. String comparison leaks timing information that could theoretically let an attacker guess the signature byte by byte. Is anyone actually going to exploit this on an indie hacker site? Probably not. Do it anyway. - Resend, not SendGrid. I chose Resend because the API is dead simple, the free tier is generous (3,000 emails/month), and I was already using it for other projects. One API call, no SDK, just fetch. Same philosophy as the Stripe integration.
Right now I'm only sending a notification email to myself. I could also send a receipt to the customer, but Stripe already does that automatically if you enable it in the Dashboard. No sense duplicating work.
session.js — The Success Page Handshake
This is the part most tutorials skip. After payment, Stripe redirects the customer to your success URL with a session_id query parameter. But you can't just trust that URL. Someone could guess or share a session ID. You need to fetch the session from Stripe's API server-side to verify it's paid and extract the product metadata.
// api/session.js
export default async function handler(req, res) {
const { session_id } = req.query;
if (!session_id) return res.status(400).json({ error: 'Missing session_id' });
// Fetch the session from Stripe
const session = await fetch(
`https://api.stripe.com/v1/checkout/sessions/${session_id}`,
{
headers: {
'Authorization': `Bearer ${process.env.STRIPE_SECRET_KEY}`,
},
}
).then(r => r.json());
if (session.payment_status !== 'paid') {
return res.status(402).json({ error: 'Payment not completed' });
}
const slug = session.metadata.slug;
// Generate a signed download URL (more on this next)
const downloadUrl = await generateSignedUrl(slug);
res.json({
product: slug,
email: session.customer_details.email,
downloadUrl,
});
}
The success page is just static HTML with a small script that calls /api/session?session_id=... on load, then renders the download link. No framework. No hydration. The server verifies the payment, the client shows the button.
This three-way handshake — Stripe redirects to your page, your page calls your API, your API calls Stripe — is the core security model. The customer never gets a download link that didn't come from a verified paid session.
Download Delivery: Supabase Signed URLs
The actual kit files (ZIP archives) live in a private Supabase Storage bucket. Private means no public URL exists. You can't guess the path and download the file. The only way to get access is through a signed URL that I generate server-side after confirming payment.
async function generateSignedUrl(slug) {
const filePath = `kits/${slug}.zip`;
const response = await fetch(
`${process.env.SUPABASE_URL}/storage/v1/object/sign/downloads/${filePath}`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.SUPABASE_SERVICE_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
expiresIn: 604800, // 7 days in seconds
}),
}
);
const { signedURL } = await response.json();
return `${process.env.SUPABASE_URL}/storage/v1${signedURL}`;
}
The signed URL expires in 7 days. That's long enough for a buyer to download at their convenience, short enough that sharing the link won't create a permanent leak. If someone needs to re-download after 7 days, they can email me and I'll generate a new link. At my current volume, that's a perfectly acceptable manual process.
I looked at S3, Cloudflare R2, and Vercel Blob. Supabase won because: (1) free tier is 1GB which is plenty for ZIP files, (2) signed URL generation is one API call, (3) I already use Supabase for other projects so I'm not adding a new vendor, and (4) the bucket privacy model is exactly what I need — files are inaccessible unless you have a signed URL or the service key.
One nice side effect of this architecture: I can update a kit without changing any payment code. I just upload a new kits/launch-kit.zip to Supabase. The next buyer gets the updated version automatically. No deploy needed.
What I'd Do Differently
I built this in a single sitting — about 2 hours from "I should sell things" to "the first test payment went through." It works. But looking back, there are things I'd reconsider:
1. I'd add an orders table from the start
Right now, Stripe is my only record of purchases. That's fine until someone emails saying they lost their download link and I have to dig through the Stripe Dashboard to find their session. A simple orders table in Supabase — just email, slug, session ID, and timestamp — would make support requests trivial. Fifteen minutes of work I keep putting off.
2. I'd use the Stripe SDK for the webhook
The raw signature verification works, but it's fiddly. The stripe.webhooks.constructEvent() helper handles edge cases I probably haven't thought of — timestamp tolerance, multiple signatures, encoding quirks. For checkout and session fetching, raw fetch is fine. For webhook verification, the SDK earns its weight.
3. I'd set up proper error monitoring earlier
For the first few weeks, my only error visibility was "did I get the Resend email notification?" If the webhook failed silently, I'd have no idea someone paid without getting their download. I've since added basic logging, but I should have done it from day one. Even a simple try/catch that sends a Resend email on error would have been better than nothing.
4. I'd consider Lemon Squeezy or Gumroad first
Honestly? If you're selling fewer than 5 products and don't care about owning the checkout flow, a managed platform handles all of this for you. I went with Stripe because I wanted to learn the API, I wanted full control over the experience, and I'm building this site as a portfolio of what I can build. If your goal is "make money selling PDFs," you don't need to roll this yourself.
But if you're like me and you want to understand every layer of the stack you're shipping — read on. That's the whole point of the Brain Kit philosophy: learn by building the thing yourself, with context about why each piece exists.
The Costs: $0/Month
Here's my favorite part. The total recurring infrastructure cost for this payment system is zero dollars.
| Service | What it does | Monthly cost |
|---|---|---|
| Vercel | Hosting + 3 serverless functions | $0 |
| Stripe | Payment processing | $0 * |
| Supabase | File storage (private bucket) | $0 |
| Resend | Sale notification emails | $0 |
| Total recurring | $0/mo | |
* Stripe charges 2.9% + 30 cents per transaction, but there's no monthly fee. You only pay when you make money. That's the dream.
Every tool here has a free tier that's more than sufficient for indie-scale volume. Vercel's free plan gives you 100GB bandwidth and 100 hours of serverless execution. Supabase gives you 1GB storage. Resend gives you 3,000 emails/month. You'd need to be doing serious volume before hitting any of these limits.
The only fixed cost is the domain. I already had dashbuilds.dev for the rest of the site, so the shop added exactly $0 in new expenses.
The Full Picture
If you're an indie dev selling digital products, you don't need a backend framework. You don't need a database (though you should probably add one — see my regrets above). You don't need to spend a weekend configuring payment infrastructure.
What you need is:
- A function to create the Checkout Session — maps your product to a Stripe price ID, returns the hosted checkout URL.
- A function to handle the webhook — verifies the signature, does whatever you need after payment (email, logging, fulfillment).
- A function to verify the session on success — confirms payment actually happened, returns download links.
That's three files. Under 200 lines total. Deployable in 2 hours. Running cost: nothing until someone actually buys something.
The products in my shop are all built the same way — simple tools that do real things without overengineering. I wrote the code. I sell the code. The system that sells the code is, itself, the kind of code I'd sell. There's something satisfying about that recursion.
The best payment infrastructure is the one you forget exists. Set it up once, test it, and go back to building things people want to buy.
Developer Kits ($9–$39)
Templates, starter configs, and blueprints for shipping faster. Every kit is built from real projects — the same architecture patterns behind this very checkout system.
Browse the shop →