Vercel Logo

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

  1. Get your test mode secret key from Stripe and add STRIPE_SECRET_KEY to .env.local
  2. Implement utils/stripe/config.ts
  3. Implement the POST handler in app/api/webhooks/route.ts
  4. Run stripe listen --forward-to localhost:3000/api/webhooks, add the signing secret as STRIPE_WEBHOOK_SECRET, and create the Ranger and Elder products

Hands-on Exercise 2.1

Requirements:

  1. Create a Stripe account and switch to test mode
  2. Add the secret key to .env.local
  3. Create a server-side Stripe client in utils/stripe/config.ts
  4. 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
  5. Forward Stripe events to your local app with the Stripe CLI
  6. 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.ts are already built: upsertProductRecord, upsertPriceRecord, deleteProductRecord, deletePriceRecord and manageSubscriptionStatusChange. 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 with req.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:

app/api/webhooks/route.ts
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

  1. Install the Stripe CLI and log in:

    brew install stripe/stripe-cli/stripe
    stripe login

    On Linux or Windows, follow Stripe's CLI install guide.

  2. 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.local as STRIPE_WEBHOOK_SECRET, then restart pnpm dev in another terminal. Leave stripe listen running.

  3. 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
  4. Watch the events arrive. Each product sends a product.created and a price.created, and each one gets a 200:

    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
    
  5. Check Supabase. Open Table Editor. products has Ranger and Elder, and prices has 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_KEY and STRIPE_WEBHOOK_SECRET are in .env.local
  • utils/stripe/config.ts exports a configured stripe client
  • app/api/webhooks/route.ts verifies the signature and returns 400 when it fails
  • stripe listen shows [200] for the Ranger and Elder product.created and price.created events
  • Supabase's products table has Ranger and Elder, and prices has 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.

.env.local
# 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

utils/stripe/config.ts
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:

app/api/webhooks/route.ts
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. constructEvent proves the request came from Stripe and wasn't changed, and a failure returns 400 before we touch the database.
  • The switch narrows the type. Inside case "price.created", TypeScript knows event.data.object is a Stripe.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 a 200, 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/webhooks

Copy 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

ModeKey PrefixBehavior
Testsk_test_No real charges, test cards work
Livesk_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(), not req.json().
  • Or STRIPE_WEBHOOK_SECRET doesn't match the secret stripe listen printed. Copy it again and restart pnpm dev.

[400] with "Missing signature or webhook secret":

  • STRIPE_WEBHOOK_SECRET isn't loaded. Check the name in .env.local, then restart pnpm dev. Next.js reads env files at startup.

[500] and "Price insert/update failed" in the dev server log:

  • The products and prices tables are missing. Run schema.sql from 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. upsertPriceRecord retries 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_KEY is missing, so the client sent an empty key. Check .env.local and restart.

Type '"2025-02-24.acacia"' is not assignable to type '"2026-09-30.endive"':

  • The apiVersion came from an older tutorial. Use the version in the error message, or remove apiVersion and 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?

supported.