the strategic case for a serverless store is elsewhere. this is the wiring. one function turns a paid stripe checkout into a printify order, and three details decide whether it is a real shop or a duplicate-order incident waiting to happen.
the loop
- customer completes stripe checkout.
- stripe fires a
checkout.session.completedwebhook at your endpoint. - the function verifies the event is really from stripe, checks it has not already handled it, reads the line items, and creates the matching printify order.
- printify prints and ships.
- a confirmation email goes out via resend.
no server runs between orders. the function wakes on the webhook and sleeps again.
the handler
illustrative, on a next.js route handler:
// app/api/stripe/webhook/route.ts
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text();
const sig = req.headers.get("stripe-signature")!;
// 1. signature: prove the event came from stripe
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body, sig, process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return new Response("bad signature", { status: 400 });
}
if (event.type !== "checkout.session.completed") {
return new Response("ignored", { status: 200 });
}
// 2. idempotency: handle each event once, even if delivered twice
const fresh = await redis.set(`evt:${event.id}`, "1", { nx: true, ex: 86400 });
if (fresh === null) return new Response("duplicate", { status: 200 });
const session = event.data.object as Stripe.Checkout.Session;
const items = await stripe.checkout.sessions.listLineItems(session.id);
// 3. the failure case: payment is already taken, never drop the order
try {
await createPrintifyOrder(session, items.data);
await sendOrderEmail(session);
} catch (err) {
await queueFailedFulfilment(event.id, session.id); // log, alert, retry later
return new Response("fulfilment deferred", { status: 200 });
}
return new Response("ok", { status: 200 });
}
the three that matter
signature. your webhook endpoint is a public url. anyone can POST to it, including someone who would like a free order. constructEvent checks the stripe-signature header against your endpoint secret and rejects anything not genuinely from stripe. skip this and your shop is an open door. read the body as raw text, not parsed json, or the signature check fails.
idempotency. stripe guarantees at-least-once delivery, which means a webhook can arrive more than once. without a guard, a re-delivered event creates a second printify order: the customer paid once and receives two parcels, and you eat the cost. key on the stripe event id, record the ones you have processed (a redis set with nx, or a unique constraint in your database), and no-op on a repeat.
the failure case. the dangerous moment is after the money is taken. stripe succeeded, then createPrintifyOrder throws: a printify hiccup, a bad variant mapping, a timeout. you must not lose this. the customer has paid and is owed goods. capture the failure durably, return 200 so stripe stops retrying into a broken path, and reconcile out of band, a retry queue, an alert, a manual fallback. silent loss here is the one failure that turns into a refund and a bad review.
testing before live keys
never point this at live keys untested. in stripe test mode, use the stripe cli: stripe listen --forward-to localhost:3000/api/stripe/webhook and stripe trigger checkout.session.completed. confirm a test payment creates a printify draft order and an email goes out, before a single real card touches it.
get the signature, the idempotency and the failure case right, and the rest is genuinely plumbing.
building this and want it done without the duplicate-order incident? our job.