I needed to sell 9 digital products on a static site. No Next.js. No Express. No backend framework at all.
Just HTML pages on Vercel and Stripe Checkout handling the payment UI.
Here's the entire architecture: 3 serverless functions. That's it.
The Problem
I had 9 developer kits ($14–$59) ready to sell. I needed:
- A checkout flow that creates Stripe sessions
- Sale notifications via email
- A success page that shows the right download links
I didn't want to spin up a backend framework for what's essentially 3 API endpoints.
The Architecture
Browser → POST /api/checkout → Stripe Checkout Session → Stripe hosted page
↓
Customer pays
↓
Stripe webhook → POST /api/webhook → Verify signature → Send email via Resend
↓
Success page → GET /api/session → Fetch session → Show download links
Three files. No framework. No database for orders (Stripe is the database).
File 1: api/checkout.js
Creates a Stripe Checkout Session. The product slug comes from the button click, gets mapped to a Stripe Price ID.
// api/checkout.js
export default async function handler(req, res) {
const { product } = req.body;
const priceMap = {
'brain-kit': 'price_1TIrnk...',
'invoice-kit': 'price_1TIrst...',
'dash-biz-suite': 'price_1TIrsz...',
// ... 9 products total
};
const response = 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({
'line_items[0][price]': priceMap[product],
'line_items[0][quantity]': '1',
'mode': 'payment',
'success_url': `https://dashbuilds.dev/success?session_id={CHECKOUT_SESSION_ID}`,
'cancel_url': 'https://dashbuilds.dev/shop',
'metadata[product]': product,
}),
});
const session = await response.json();
res.json({ url: session.url });
}
No Stripe SDK. Just fetch to the Stripe API with URL-encoded params. The SDK is nice but it's a dependency I didn't need for one endpoint.
File 2: api/webhook.js
Stripe sends a checkout.session.completed event after payment. This function verifies the signature and sends me a notification email via Resend.
// api/webhook.js
import { buffer } from 'micro';
import Stripe from 'stripe';
export default async function handler(req, res) {
const buf = await buffer(req);
const sig = req.headers['stripe-signature'];
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const event = stripe.webhooks.constructEvent(
buf, sig, process.env.STRIPE_WEBHOOK_SECRET
);
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 <sales@dashbuilds.dev>',
to: 'dash@dashbuilds.dev',
subject: `New sale: ${session.metadata.product}`,
html: `<p>${session.customer_details.email} bought ${session.metadata.product} for $${session.amount_total / 100}</p>`,
}),
});
}
res.json({ received: true });
}
I used the Stripe SDK here because signature verification with raw crypto is error-prone. Worth the dependency for one function.
File 3: api/session.js
The success page loads with a session_id query param. This function fetches the session so I can show the right download links.
// api/session.js
export default async function handler(req, res) {
const { session_id } = req.query;
const response = await fetch(
`https://api.stripe.com/v1/checkout/sessions/${session_id}`,
{ headers: { 'Authorization': `Bearer ${process.env.STRIPE_SECRET_KEY}` } }
);
const session = await response.json();
// Generate signed download URL from Supabase Storage
const product = session.metadata.product;
const { data } = await supabase.storage
.from('kit-downloads')
.createSignedUrl(`${product}.zip`, 604800); // 7-day expiry
res.json({
product: session.metadata.product,
email: session.customer_details.email,
download_url: data.signedUrl,
});
}
Downloads live in a private Supabase Storage bucket. Signed URLs expire after 7 days. The success page tells customers to bookmark it.
The Cost
| Service | Monthly Cost |
|---|---|
| Vercel (hosting + serverless) | $0 |
| Stripe (per transaction only) | $0 |
| Resend (email notifications) | $0 |
| Supabase (download storage) | $0 |
| Total | $0/month |
Stripe takes 2.9% + $0.30 per transaction. Everything else is free tier.
What I'd Do Differently
1. Use the Stripe SDK for checkout too. I went with raw fetch to avoid dependencies but the URL-encoded params are annoying to maintain. The SDK is worth it.
2. Add an orders table. Stripe is technically the source of truth, but having a local record makes it easier to debug "I didn't get my download" emails.
3. Set up error monitoring from day one. I had a silent webhook failure for 2 days before I noticed. Resend was returning 422 because I hadn't verified my sending domain.
4. Consider Lemon Squeezy. If you want to sell digital products and don't care about custom checkout flows, Lemon Squeezy handles tax, delivery, and licensing out of the box. I went with Stripe because I wanted full control, but for most people Lemon Squeezy is less work.
The Takeaway
You don't need a backend framework to sell digital products. Three serverless functions handle the entire flow: create session, verify payment, deliver download.
The hard part isn't the code — it's the Stripe Dashboard setup (products, prices, webhook endpoints, tax settings) and testing the full flow end-to-end before going live.
This post was originally published on dashbuilds.dev.
I sell deploy-ready developer kits at dashbuilds.dev/shop — the checkout flow from this post is what powers it.
Top comments (0)