Save products you love by clicking the heart icon.
Comprehensive guide to modern container image building tools, patterns, and security hardening techniques for production deployments.
When I set out to sell digital products on graphwiz.ai and tobias-weiss.org, I had a clear requirement: customers should land on a Stripe-hosted checkout page, pay, and instantly receive their download — all without me building a cart UI, handling PCI compliance, or storing credit card numbers.
Stripe Checkout was the obvious choice. It handles the payment UI, supports multiple currencies (EUR/USD), Pay What You Want pricing, and Stripe's infrastructure handles compliance. But integrating it cleanly into a Next.js app, wiring up webhooks for fulfillment, and ensuring the CSP doesn't break Stripe.js required some careful architecture.
This article walks through exactly how I built it — the architecture decisions, the code patterns, and the lessons learned along the way.
User → BuyButton → /api/stripe/create-checkout → Stripe Checkout (hosted)
↓
Success URL ← stripe.com
↓
Success Page (verify session)
↓
Download + Upsell
↓
Webhook (fulfillment + email)
The flow is intentionally stateless. No customer data is stored until after payment succeeds. The checkout session carries everything — product name, slug, download URL — in its metadata.
I evaluated several platforms before building this:
| Approach | Pros | Cons |
|---|---|---|
| Stripe Checkout (my choice) | Full control, no monthly fee, own branding | Need to build store UI, handle webhooks |
| Shopify | Turnkey store, analytics | Monthly fees ($39+), locked into ecosystem |
| Gumroad | Instant setup, built-in email | 8.5% + $0.30 per sale, limited customization |
| LemonSqueezy | Tax handling, email delivery | Third-party dependency, less control |
For a DevOps-oriented site, owning the integration was the right call. The Stripe API is excellent, and the monthly savings at scale pay for the development time.
The core of the integration is a single API route. It receives a price ID and product metadata, creates a Stripe Checkout Session, and returns the redirect URL.
// app/api/stripe/create-checkout/route.ts
import { NextResponse } from "next/server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
const { priceId, productName, productSlug, customerEmail } = await request.json();
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: [{ price: priceId, quantity: 1 }],
customer_email: customerEmail,
success_url: `${origin}/store/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/store`,
metadata: { productName, productSlug, downloadUrl },
});
return NextResponse.json({ checkoutURL: session.url });
}
Key details:
{CHECKOUT_SESSION_ID} is a Stripe template — Stripe substitutes the actual session ID at redirect timeAfter payment, Stripe redirects to /store/success?session_id=cs_.... The success page verifies the session server-side:
// app/store/success/page.tsx
const session = await retrieveCheckoutSession(sessionId);
productName = session.metadata?.productName;
if (session.payment_status === "paid") {
// Generate download token
downloadToken = Buffer.from(
JSON.stringify({
session_id: sessionId,
product_slug: productSlug,
timestamp: Date.now(),
}),
).toString("base64");
}
The download token is self-contained — it doesn't require a database. The download API route decodes and verifies the token against Stripe's API on each request. This means tokens survive server restarts (stateless by design).
The webhook receives events from Stripe after checkout completion. This is the "reliable path" — Stripe retries delivery if the endpoint is down, unlike the success page redirect which the customer must follow immediately.
// app/api/stripe/webhook/route.ts
const event = constructWebhookEvent(body, signature);
switch (event.type) {
case "checkout.session.completed": {
// Log purchase to purchases.json
// Send confirmation email via local postfix relay
// Generate download token
break;
}
}
The webhook must verify the Stripe signature with constructWebhookEvent(). Without this, anyone who discovers your webhook URL can forge events and trigger free downloads.
A common pitfall: the webhook secret lives in your .env.production but is not automatically configured in Stripe's Dashboard. You must create a webhook endpoint in the Stripe Dashboard (or via API) that points to https://yoursite.com/api/stripe/webhook.
curl https://api.stripe.com/v1/webhook_endpoints \
-u "sk_live_..." \
-d "url=https://yoursite.com/api/stripe/webhook" \
-d "enabled_events[]=checkout.session.completed" \
-d "enabled_events[]=checkout.session.expired"
The whsec_... secret returned by this API call must match your STRIPE_WEBHOOK_SECRET. They are a matched pair — if you regenerate one, the other breaks.
When the webhook fires, I send a confirmation email via a local postfix relay running on the server. This avoids third-party email services:
const transporter = nodemailer.createTransport({
host: "127.0.0.1",
port: 2525,
secure: false,
ignoreTLS: true,
});
await transporter.sendMail({
from: '"Store" <noreply@tobias-weiss.org>',
replyTo: customerEmail,
to: process.env.CONTACT_EMAIL,
subject: "New Purchase: " + productName,
text: "..." + downloadLink + "...",
});
The email includes the download link as a backup — even if the customer closes the browser after payment, they get the link in their inbox.
Stripe.js requires specific CSP directives. Here's the tightest config that works:
script-src 'self' 'unsafe-inline' https://js.stripe.com;
connect-src 'self' https://api.stripe.com;
frame-src https://js.stripe.com;
img-src https://*.stripe.com;
Note: 'unsafe-inline' is required for Next.js runtime chunks and Stripe.js. You cannot remove it without breaking either framework.
Stripe limits metadata values to 500 characters each. If your product name is long, truncate it. The downloadUrl must be short.
All price IDs in test mode (price_1Test...) are completely separate from live (price_1Live...). You need to create separate prices in both environments. The same applies to webhook endpoints — Stripe has separate test and live webhook configurations.
{CHECKOUT_SESSION_ID} TemplateThis template variable only works in the success_url and cancel_url of a Checkout Session. It is not a general Stripe feature. I initially omitted it — the success page could not verify the purchase because it had no session ID.
If you ever switch from Checkout to Stripe Elements (embedded payment form), you need additional CSP directives for https://m.stripe.com and https://js.stripe.com. Checkout handles the payment form on Stripe's domain, so this is simpler.
Stripe retries failed webhook deliveries for up to 3 days. Your handler must be idempotent — processing the same event twice should have no additional side effects. My implementation checks for existing entries in purchases.json before adding duplicates.
The Stripe integration pattern I've described is reusable. Here are products I'm considering building from it:
Each of these would fit naturally in the store alongside the existing DevOps stacks.
Stripe Checkout + Next.js is a powerful combination for selling digital products. The architecture is simple — API route → Stripe Checkout → success page + webhook — and scales from €1 community supporter tokens to €79 production guides.
The key to making it work in production:
.env.productionThe complete code for both stores is open-source at github.com/tobias-weiss-ai-xr.