Back to path
IntermediateInvoiceDesk · Project 5 of 12 ~8h· 5 milestones

Take real money with checkout and webhooks

Continues from the last build: It is multi-tenant: orgs, roles, invites, and org-scoped data with isolation tests.

Multi-tenancy is solid now. Every row belongs to an org, every query is scoped, invites work.

Stripe CheckoutWebhook signature verificationIdempotent event handlingMoney as integer centsState machine designDrizzle migrationsServer actionsNext.js route handlers

What you'll build

A payments table and an honest invoice status machine (draft, sent, paid, void, no skipping), a public /pay/[token] page that builds a Stripe Checkout Session from real line items in cents, a signature-verified webhook route that handles checkout.session.completed and payment_intent.payment_failed exactly once even under retries, and a reconcile-on-view fallback so an invoice self-heals to paid even if the webhook never shows up.

See how we teach, before you sign up

You don't just get code dumped on you. Every starter file and every solution is explained line-by-line, in plain English. Here's one real file from this project:

lib/stripe.tsts
import Stripe from "stripe";

if (!process.env.STRIPE_SECRET_KEY) {
  throw new Error("STRIPE_SECRET_KEY is not set");
}

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
  apiVersion: "2024-06-20",
});

Reading this file

  • if (!process.env.STRIPE_SECRET_KEY) {Fails fast at startup instead of throwing a confusing error the first time someone hits the pay page.
  • apiVersion: "2024-06-20",Pinning the API version means Stripe's future breaking changes do not silently alter your webhook payload shape.
  • export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {One shared client instance, imported everywhere Stripe is called from server code.

Starter file for this rung.

That's 1 of 9 explained code blocks in this single project.

The build, milestone by milestone

  1. 1

    Model payment state honestly

    6 guided steps

    If the schema allows draft -> paid directly, or sent -> draft, every report you build later on top of it is unreliable. A state machine you enforce in code is cheaper than a data cleanup script six months from now.

  2. 2

    Create Checkout Sessions server-side

    6 guided steps

    Clients paying an invoice do not have an InvoiceDesk account and should never need one. The Checkout Session must be created server-side so the amount comes from your database, not from anything the browser can tamper with.

  3. 3

    Verify webhook signatures

    6 guided steps

    The webhook URL is public. Anyone who finds it can POST a fake checkout.session.completed event claiming an invoice is paid. Signature verification is the only thing standing between your database and a forged payment.

  4. 4

    Handle events idempotently

    6 guided steps

    Stripe explicitly documents that webhooks can be delivered more than once for the same event, network retries, dashboard resends, and duplicate delivery are all normal. If your handler is not idempotent, a retried checkout.session.completed can double-charge your own bookkeeping even though Stripe only charged the customer once.

  5. 5

    Reconcile and display

    5 guided steps

    Webhooks are reliable most of the time but not all of the time: a firewall, a deploy mid-flight, or a Stripe outage can mean an event never lands. If the only way to fix that is a support ticket, the feature is not actually done. A cheap self-heal on page load closes that gap.

What's inside when you start

4 starter files, ready to clone
5 guided milestones
5 full reference solutions
9 code blocks explained line-by-line
5 "is it working?" checks
6 interview questions it prepares you for

You'll walk away with

payments and processed_events tables live in Postgres with the invoice status enum enforcing draft, sent, paid, void with no skipping
/pay/[token] public page that builds a Stripe Checkout Session from real invoice_items in cents and redirects to Stripe
Signature-verified /api/stripe/webhook route that returns 400 on a bad signature and 200 on a valid one
checkout.session.completed and payment_intent.payment_failed handled exactly once each, verified by triggering the same event twice
Invoice detail page showing a status badge, paidAt, and a working Stripe receipt link
Reconcile-on-view fallback that self-heals a sent invoice to paid when the webhook never arrived

This is portfolio-grade. Build it free.

Sign up to unlock every milestone step-by-step, the code skeletons, full reference solutions, and checkable tasks, with your progress saved as you build.

Start building