Connect Stripe to the Foragers Guild
Picture the Guild's first paying member. Someone signs up for Ranger, types in a test card, and Stripe takes the $9. Stripe knows they paid. Supabase knows they're logged in. Our app checks Supabase before it shows them the mushroom database, finds nothing, and politely keeps them out.
The two systems don't talk to each other yet. This lesson wires up both directions. The Stripe SDK lets our server call Stripe. A webhook lets Stripe call us back whenever a product, price or subscription changes, so Supabase always has a current copy.
Outcome
Configure the server-side Stripe SDK and a webhook route that copies Stripe products, prices and subscriptions into Supabase.
Fast Track
- Get your test mode secret key from Stripe and add
STRIPE_SECRET_KEYto.env.local - Implement
utils/stripe/config.ts - Implement the
POSThandler inapp/api/webhooks/route.ts - Run
stripe listen --forward-to localhost:3000/api/webhooks, add the signing secret asSTRIPE_WEBHOOK_SECRET, and create the Ranger and Elder products
Hands-on Exercise 2.1
Requirements:
- Create a Stripe account and switch to test mode
- Add the secret key to
.env.local - Create a server-side Stripe client in
utils/stripe/config.ts - Implement the webhook route in
app/api/webhooks/route.ts:- Verify the request came from Stripe
- Ignore events the app doesn't care about
- Hand each relevant event to the matching helper in
utils/supabase/admin.ts
- Forward Stripe events to your local app with the Stripe CLI
- Create two products in Stripe, Ranger at $9/month and Elder at $29/month, and confirm they appear in Supabase
Implementation hints:
- The secret key starts with
sk_test_in test mode. It never goes in browser code. - The sync helpers in
utils/supabase/admin.tsare already built:upsertProductRecord,upsertPriceRecord,deleteProductRecord,deletePriceRecordandmanageSubscriptionStatusChange. The route decides which one to call. stripe.webhooks.constructEvent(body, signature, secret)checks the signature. It needs the body exactly as Stripe sent it, so read it withreq.text().- Any response that isn't a 2xx tells Stripe the delivery failed, and Stripe tries again later.
The tempting version
The first webhook most people write parses the JSON, because that's what every other route does:
export async function POST(req: Request) {
const body = await req.json();
const signature = req.headers.get("stripe-signature")!;
const event = stripe.webhooks.constructEvent(
JSON.stringify(body),
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
// ...
}It looks fine, and every request fails:
Webhook Error: No signatures found matching the expected signature for payload. Are you passing the raw request body you received from Stripe?
This is expected. Stripe signs the exact bytes it sends. Parsing and re-stringifying the JSON changes the spacing and key order, so the signature no longer matches. Read the body with req.text() and pass that string to constructEvent untouched.
Try It
-
Install the Stripe CLI and log in:
brew install stripe/stripe-cli/stripe stripe loginOn Linux or Windows, follow Stripe's CLI install guide.
-
Forward events to your app:
stripe listen --forward-to localhost:3000/api/webhooks> Ready! You are using Stripe API Version [2026-09-30.endive]. Your webhook signing secret is whsec_4f9c2a17e8b3d6... (^C to quit)Copy the
whsec_value into.env.localasSTRIPE_WEBHOOK_SECRET, then restartpnpm devin another terminal. Leavestripe listenrunning. -
Create the membership tiers in the Stripe dashboard:
- Ranger: "Mushroom database, seasonal guides, safe vs deadly comparisons," $9.00/month, recurring
- Elder: "Full archive, medicinal uses, offline field guide, submit your own finds," $29.00/month, recurring
-
Watch the events arrive. Each product sends a
product.createdand aprice.created, and each one gets a200:2026-11-03 10:15:02 --> product.created [evt_1SPqK2LkdF8xQ7vRb3nYw0aZ] 2026-11-03 10:15:02 <-- [200] POST http://localhost:3000/api/webhooks [evt_1SPqK2LkdF8xQ7vRb3nYw0aZ] 2026-11-03 10:15:03 --> price.created [evt_1SPqK3LkdF8xQ7vRm9TcE2hD] 2026-11-03 10:15:03 <-- [200] POST http://localhost:3000/api/webhooks [evt_1SPqK3LkdF8xQ7vRm9TcE2hD]The dev server logs the same thing from our side:
Product inserted/updated: prod_TJ8kV2mQx4Lr7N Price inserted/updated: price_1SPqK3LkdF8xQ7vRa1Bc -
Check Supabase. Open Table Editor.
productshas Ranger and Elder, andpriceshas a 900 and a 2900 row. Stripe stores amounts in cents.
You may see a few other events go by too. The route answers every one with a 200 and ignores the ones it doesn't handle, so Stripe doesn't retry them.
Commit
git add -A
git commit -m "feat(billing): connect Stripe and sync products with a webhook"Done-When
STRIPE_SECRET_KEYandSTRIPE_WEBHOOK_SECRETare in.env.localutils/stripe/config.tsexports a configuredstripeclientapp/api/webhooks/route.tsverifies the signature and returns400when it failsstripe listenshows[200]for the Ranger and Elderproduct.createdandprice.createdevents- Supabase's
productstable has Ranger and Elder, andpriceshas their monthly prices
Solution
Step 1: Get your secret key
In the Stripe dashboard, switch on Test mode, then open Developers → API keys. Reveal and copy the Secret key (sk_test_...). The page also shows a Publishable key (pk_test_...). This app doesn't use it, because checkout happens on a page Stripe hosts.
# Existing Supabase vars
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_...
SUPABASE_SECRET_KEY=sb_secret_...
# Stripe
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...Step 2: Create the Stripe client
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? "", {
apiVersion: "2026-09-30.endive",
appInfo: {
name: "Foragers Guild",
version: "1.0.0",
},
});Each stripe major pins one API version (2026-09-30.endive for stripe@23), and TypeScript rejects any other value. appInfo labels our requests in Stripe's logs.
Step 3: Write the webhook route
The starter already lists the events we care about. We fill in the handler:
import { stripe } from "@/utils/stripe/config";
import {
manageSubscriptionStatusChange,
upsertPriceRecord,
upsertProductRecord,
deletePriceRecord,
deleteProductRecord,
} from "@/utils/supabase/admin";
import Stripe from "stripe";
// Stripe events we care about for subscription management
const relevantEvents = new Set([
"product.created",
"product.updated",
"product.deleted",
"price.created",
"price.updated",
"price.deleted",
"checkout.session.completed",
"customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted",
]);
export async function POST(req: Request) {
// The signature covers the exact bytes Stripe sent, so read the raw text.
const body = await req.text();
const signature = req.headers.get("stripe-signature");
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
let event: Stripe.Event;
try {
if (!signature || !webhookSecret) {
return new Response("Missing signature or webhook secret", { status: 400 });
}
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
console.log(`Webhook signature check failed: ${message}`);
return new Response(`Webhook Error: ${message}`, { status: 400 });
}
if (!relevantEvents.has(event.type)) {
return Response.json({ received: true });
}
try {
switch (event.type) {
case "product.created":
case "product.updated":
await upsertProductRecord(event.data.object);
break;
case "product.deleted":
await deleteProductRecord(event.data.object);
break;
case "price.created":
case "price.updated":
await upsertPriceRecord(event.data.object);
break;
case "price.deleted":
await deletePriceRecord(event.data.object);
break;
case "customer.subscription.created":
case "customer.subscription.updated":
case "customer.subscription.deleted": {
const subscription = event.data.object;
await manageSubscriptionStatusChange(
subscription.id,
subscription.customer as string
);
break;
}
case "checkout.session.completed": {
const session = event.data.object;
if (session.mode === "subscription") {
await manageSubscriptionStatusChange(
session.subscription as string,
session.customer as string
);
}
break;
}
}
} catch (error) {
console.log(error);
// A non-2xx response tells Stripe to retry the event later.
return new Response("Webhook handler failed. Check your function logs.", { status: 500 });
}
return Response.json({ received: true });
}The parts worth slowing down for:
- The signature check comes first. Anyone can POST to
/api/webhooks.constructEventproves the request came from Stripe and wasn't changed, and a failure returns400before we touch the database. - The
switchnarrows the type. Insidecase "price.created", TypeScript knowsevent.data.objectis aStripe.Price, so each helper gets the object it expects. - Failures return
500. If Supabase is down for a moment, Stripe retries the event instead of losing it. Unrelated events still get a200, so Stripe doesn't retry those forever.
The subscription cases won't fire until 2.3, when someone checks out. The route is ready for them now.
Step 4: Forward events locally
stripe listen --forward-to localhost:3000/api/webhooksCopy the whsec_ secret it prints into .env.local, restart pnpm dev, and create Ranger and Elder in the dashboard. Production gets its own endpoint and secret in 4.3.
File Structure After This Lesson
app/api/webhooks/
└── route.ts ← New: verifies and routes Stripe events
utils/
├── supabase/
│ ├── admin.ts ← In starter: the sync helpers the webhook calls
│ └── ... ← From Section 1
└── stripe/
└── config.ts ← New: server Stripe client
Test Mode vs Live Mode
| Mode | Key Prefix | Behavior |
|---|---|---|
| Test | sk_test_ | No real charges, test cards work |
| Live | sk_live_ | Real charges, real cards only |
Develop with test keys. Live keys and a production webhook come in 4.3.
Troubleshooting
[400] with "No signatures found matching the expected signature for payload":
- The route parsed the body before checking it. Use
await req.text(), notreq.json(). - Or
STRIPE_WEBHOOK_SECRETdoesn't match the secretstripe listenprinted. Copy it again and restartpnpm dev.
[400] with "Missing signature or webhook secret":
STRIPE_WEBHOOK_SECRETisn't loaded. Check the name in.env.local, then restartpnpm dev. Next.js reads env files at startup.
[500] and "Price insert/update failed" in the dev server log:
- The
productsandpricestables are missing. Runschema.sqlfrom the starter in the Supabase SQL Editor (Section 1), then edit the product and its price in Stripe (a description change is enough) to send the events again.
Products show up in Supabase but a price doesn't:
- Stripe doesn't promise event order, so a price can arrive before its product.
upsertPriceRecordretries for a few seconds. If the log shows it gave up, edit the price's description in Stripe to resend it.
"You did not provide an API key":
STRIPE_SECRET_KEYis missing, so the client sent an empty key. Check.env.localand restart.
Type '"2025-02-24.acacia"' is not assignable to type '"2026-09-30.endive"':
- The
apiVersioncame from an older tutorial. Use the version in the error message, or removeapiVersionand let the SDK send its pinned version.
Stripe and Supabase are talking now. Next, we put those tiers on a pricing page.
Was this helpful?