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.
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:
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
Model payment state honestly
6 guided stepsIf 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
Create Checkout Sessions server-side
6 guided stepsClients 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
Verify webhook signatures
6 guided stepsThe 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
Handle events idempotently
6 guided stepsStripe 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
Reconcile and display
5 guided stepsWebhooks 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
You'll walk away with
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