Stripe Webhooks Guide: Connect Events to Your App
A practical stripe webhooks guide for Next.js apps: verify events, update your database, handle retries, and ship safer payments.
Build My App Fast · Oct 4, 2026 · 12 min read
A stripe webhooks guide starts with one rule: do not unlock paid features from the browser redirect alone. Stripe webhooks are server-to-server event notifications that tell your app what actually happened: a checkout completed, a subscription changed, a payment failed, or a refund was issued. Your app should verify the event signature, update your database idempotently, and return a success response only after it has safely recorded the result.
If you are building a SaaS app with Next.js, Supabase, Stripe, Tailwind, Resend, and Vercel, webhooks are the part that turns “the payment page worked” into “the product knows who should have access.” This guide walks through the practical version we use when building production-ready MVPs.
Stripe webhooks guide: what webhooks actually do

A webhook is an HTTP request sent by Stripe to an endpoint in your app. Instead of your app constantly asking Stripe, “Did anything change?”, Stripe pushes events to you.
For example:
- A user clicks “Subscribe.”
- Your app creates a Stripe Checkout Session.
- The user pays on Stripe-hosted Checkout.
- Stripe redirects the user back to your app.
- Separately, Stripe sends your webhook endpoint an event such as
checkout.session.completed. - Your backend verifies that event and updates your database.
- Your app reads your database to decide whether the user has access.
That separation matters. The browser redirect is a user experience step. The webhook is the reliable backend confirmation.
Stripe’s official webhook docs are worth bookmarking: Stripe webhooks documentation. For a Next.js App Router implementation, you will also touch route handlers: Next.js route handlers documentation.
If you are still designing the payment flow itself, start with our related guide on Stripe Next.js payments. This article focuses on what happens after Stripe emits events.
The events most MVPs need first
Stripe has many event types. Your MVP does not need all of them. Start with the events that affect product access, billing status, and customer communication.
| Stripe event | When it fires | What your app should usually do |
|---|---|---|
checkout.session.completed | A Checkout Session completes | Attach the Stripe customer/subscription to the user or organization |
customer.subscription.created | A subscription is created | Store subscription ID, status, customer ID, and plan reference |
customer.subscription.updated | A subscription changes | Update status, plan, cancellation settings, or renewal data |
customer.subscription.deleted | A subscription ends | Remove or downgrade paid access |
invoice.payment_failed | A payment attempt fails | Mark billing issue and optionally send an email |
invoice.paid | An invoice is paid | Confirm active billing state or record payment history |
charge.refunded | A charge is refunded | Record refund and adjust access if your policy requires it |
For many subscription MVPs, the core access model can be driven by checkout.session.completed, customer.subscription.updated, and customer.subscription.deleted. Add invoice and refund events when you need billing history, payment failure emails, or admin reporting.
If your product is subscription-heavy, read how to add subscriptions to app MVPs alongside this guide. The webhook design depends on whether you sell one-time purchases, recurring plans, usage-based billing, or team accounts.
The production mental model: Stripe is external truth, your database is app truth
A common mistake is checking Stripe live every time a user loads a dashboard. That sounds safe, but it makes your app slower, more fragile, and harder to reason about.
A better model:
- Stripe is the source of truth for payments and billing events.
- Your database is the source of truth for app access.
- Webhooks synchronize important Stripe changes into your database.
- Your app gates features based on your own tables.
For example, your organizations table might have:
stripe_customer_idstripe_subscription_idsubscription_statusplan_keycurrent_period_end
Your app should not ask Stripe whether a user can open the dashboard on every request. It should check subscription_status in your database. The webhook keeps that field current.
This is one of the small architectural choices that separates a demo from a maintainable SaaS. For the broader app structure, see the anatomy of a production-ready SaaS architecture.
Next.js webhook route: the important pieces
In a Next.js App Router project, your webhook route might live at:
app/api/stripe/webhook/route.ts
The critical detail: Stripe signature verification needs the raw request body. Do not call request.json() before verifying the signature.
A simplified route looks like this:
import { NextResponse } from "next/server";
import Stripe from "stripe";
import { createClient } from "@supabase/supabase-js";
export const runtime = "nodejs";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
export async function POST(request: Request) {
const signature = request.headers.get("stripe-signature");
if (!signature) {
return NextResponse.json({ error: "Missing signature" }, { status: 400 });
}
let event: Stripe.Event;
try {
const rawBody = await request.text();
event = stripe.webhooks.constructEvent(
rawBody,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
try {
await recordStripeEvent(event);
await handleStripeEvent(event);
await markStripeEventProcessed(event.id);
return NextResponse.json({ received: true });
} catch (error) {
await markStripeEventFailed(event.id, error);
return NextResponse.json(
{ error: "Webhook processing failed" },
{ status: 500 }
);
}
}
That example intentionally hides the helper functions, because the exact table names vary. But the shape is what matters:
- Read the
stripe-signatureheader. - Read the raw body with
request.text(). - Verify with
stripe.webhooks.constructEvent(). - Record the event ID.
- Process the event.
- Return
200only when you are done.
Never put your Stripe secret key or Supabase service role key in browser code. In Next.js, secrets used by the webhook route should not be prefixed with NEXT_PUBLIC_.
Store events before doing business logic
Stripe may retry webhook delivery. Your endpoint may receive the same event more than once. Your code should be idempotent, meaning processing the same event twice does not create duplicate records, double-send emails, or grant access twice.
A practical Supabase table for webhook events can be small:
create table stripe_events (
id text primary key,
type text not null,
livemode boolean not null,
payload jsonb not null,
processed_at timestamptz,
error text,
created_at timestamptz not null default now()
);
Then, when an event arrives:
- Insert the event using Stripe’s event ID as the primary key.
- If the event already exists and
processed_atis set, return success. - If the event exists but was not processed, decide whether to retry processing.
- Make the downstream database update idempotent too.
For example, updating an organization by stripe_subscription_id is safer than inserting a new “active subscription” row every time.
The event log also gives you an audit trail. When a founder says, “A customer paid but still cannot access the app,” you can inspect whether Stripe sent the event, whether your endpoint received it, and whether processing failed.
Handling checkout.session.completed
For many MVPs, Checkout Session completion is the first important event. It tells you that the customer completed the Checkout flow.
A typical handler does this:
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
const userId = session.metadata?.userId;
const organizationId = session.metadata?.organizationId;
if (!userId || !organizationId) {
throw new Error("Missing checkout metadata");
}
await supabase
.from("organizations")
.update({
stripe_customer_id: session.customer as string,
stripe_subscription_id: session.subscription as string,
subscription_status: "checkout_completed"
})
.eq("id", organizationId);
}
The important part is metadata. When you create the Checkout Session, include your internal IDs:
const session = await stripe.checkout.sessions.create({
mode: "subscription",
customer_email: user.email,
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${appUrl}/billing/success`,
cancel_url: `${appUrl}/billing/cancel`,
metadata: {
userId: user.id,
organizationId: organization.id
},
subscription_data: {
metadata: {
userId: user.id,
organizationId: organization.id
}
}
});
Put metadata on both the Checkout Session and the subscription data when you need it later in subscription events. Do not depend only on email matching. Emails can change, team accounts can have multiple users, and customer support flows get messy fast.
Handling subscription updates and cancellations

A subscription can change after checkout. The customer might upgrade, downgrade, cancel, renew, fail payment, or be manually adjusted inside Stripe.
Your webhook handler should route by event type:
async function handleStripeEvent(event: Stripe.Event) {
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
await handleCheckoutCompleted(session);
break;
}
case "customer.subscription.updated":
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
await syncSubscription(subscription);
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
await handlePaymentFailed(invoice);
break;
}
default:
break;
}
}
Your syncSubscription function should update your own access fields based on the subscription object:
async function syncSubscription(subscription: Stripe.Subscription) {
await supabase
.from("organizations")
.update({
stripe_customer_id: subscription.customer as string,
stripe_subscription_id: subscription.id,
subscription_status: subscription.status
})
.eq("stripe_subscription_id", subscription.id);
}
In a more complete app, you might also store plan lookup keys, renewal dates, cancellation flags, and billing portal links. Start with the fields your UI actually needs.
Local testing with the Stripe CLI
Do not wait until production to test webhooks. Stripe provides a CLI that forwards test events to your local machine.
A normal local workflow:
stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook
The CLI prints a webhook signing secret. Put that in your local .env file as STRIPE_WEBHOOK_SECRET.
Then trigger events:
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failed
For a real end-to-end test, run through your app’s actual Checkout flow in Stripe test mode. The triggered fixtures are useful, but your real flow verifies metadata, price IDs, redirects, database updates, and user access together.
Deployment checklist for Stripe webhooks
Before you go live, check the boring details. Most webhook bugs are not caused by complex billing logic. They are caused by the wrong secret, the wrong endpoint URL, missing metadata, or unsafe retry behavior.
- The webhook endpoint is deployed at a stable HTTPS URL.
- The endpoint uses the raw request body for signature verification.
-
STRIPE_SECRET_KEYis server-only. -
STRIPE_WEBHOOK_SECRETmatches the Stripe endpoint, not a different local or test endpoint. - Test and live Stripe modes use separate keys and webhook secrets.
- Events are recorded with a unique event ID before business logic runs.
- Duplicate events do not create duplicate subscriptions, emails, or access records.
- Checkout Sessions include internal metadata such as user ID or organization ID.
- The app gates paid features from your database, not from the success page URL.
- Failed webhook processing returns a non-2xx response so Stripe can retry.
- Logs show event type, event ID, and processing outcome.
- Your launch checklist includes a real test purchase, cancellation, and payment failure path.
For a wider pre-launch pass, use our MVP launch checklist.
Common mistakes that break webhook flows
The first mistake is treating the success URL as payment confirmation. Anyone can refresh a success page. Your backend should unlock access only after verified Stripe events update the database.
The second mistake is skipping idempotency. Stripe can send the same event more than once. Your app should assume duplicate delivery is normal.
The third mistake is verifying the wrong body. If your route parses JSON before calling constructEvent, signature verification can fail because the raw payload changed.
The fourth mistake is mixing test and live secrets. A webhook signing secret is tied to a specific endpoint. Your local Stripe CLI secret, test dashboard endpoint secret, and live dashboard endpoint secret are different values.
The fifth mistake is not logging enough. You do not need noisy logs forever, but during launch you want to know which event arrived, what handler ran, and what database row changed.
How this fits into a fast MVP build
For a proof of concept, you may not need full subscription lifecycle handling. You might only need a mocked billing state or a test Checkout flow.
For a real SaaS MVP, webhooks become part of the core product. If users can pay, cancel, upgrade, or lose access, your app needs backend synchronization.
At Build My App Fast, our tiers are intentionally fixed:
- $1,000 “Proof of concept” — proof of concept, delivered in 2–4 days
- $5,000 “Real app” — full app with logins and a database, delivered in 4–6 days
- $10,000 “Launchable MVP” — advanced MVP with subscriptions, integrations, or AI features, delivered in 7–10 days
Stripe subscriptions, billing portals, webhooks, transactional emails, and admin visibility usually belong in the $10,000 Launchable MVP tier because they affect real customer access and revenue operations. The goal is not to overbuild. The goal is to avoid the fragile version where payments appear to work until the first cancellation, failed payment, or duplicate webhook.
FAQ
Do I need webhooks if I use Stripe Checkout?
Yes, if the payment affects access inside your app. Stripe Checkout handles the payment UI, but your app still needs a secure backend way to learn what happened. Webhooks provide that confirmation.
Can I update my database from the success page instead?
You should not rely on that. The success page is controlled by the browser flow. A user might close the tab, refresh, lose connection, or land on a URL that does not reflect the real billing state. Use the webhook as the backend source for access changes.
Which Stripe webhook events should I start with?
For a subscription MVP, start with checkout.session.completed, customer.subscription.updated, customer.subscription.deleted, and invoice.payment_failed. Add more events only when your product or support process needs them.
Where should I store the webhook secret?
Store it as a server-side environment variable, usually STRIPE_WEBHOOK_SECRET. On Vercel, add it to the project environment variables. Do not expose it with NEXT_PUBLIC_, and keep separate values for local, test, and live endpoints.
Build it once, wire it safely
Stripe webhooks are not glamorous, but they are one of the difference-makers between a demo and a real app. If you want a Next.js, Supabase, and Stripe app built with working payments, verified webhooks, and full code ownership, apply here.
