---
title: "Connect Stripe"
description: "Configure the server-side Stripe SDK, then write the webhook route that keeps Supabase in sync with Stripe, and watch the Guild's membership tiers arrive in your database."
canonical_url: "https://vercel.com/academy/subscription-store/stripe-sdk-setup"
md_url: "https://vercel.com/academy/subscription-store/stripe-sdk-setup.md"
docset_id: "vercel-academy"
doc_version: "1.0"
last_updated: "2026-11-06T19:33:45.475Z"
content_type: "lesson"
course: "subscription-store"
course_title: "Launch a Subscription Store with Vercel and Stripe"
prerequisites:  []
---

<agent-instructions>
Vercel Academy — structured learning, not reference docs.
Lessons are sequenced.
Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume.
The lesson shows one path; if the human's project diverges, adapt concepts to their setup.
Preserve the learning goal over literal steps.
Quizzes are pedagogical — engage, don't spoil.
Quiz answers are included for your reference.
</agent-instructions>

# Connect Stripe

# 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:

```typescript title="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:**

   ```bash
   brew install stripe/stripe-cli/stripe
   stripe login
   ```

   On Linux or Windows, follow [Stripe's CLI install guide](https://docs.stripe.com/stripe-cli).

2. **Forward events to your app:**

   ```bash
   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](https://dashboard.stripe.com/test/products):
   - **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

```bash
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.

```bash title=".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

```typescript title="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:

```typescript title="app/api/webhooks/route.ts" {25-91}
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

```bash
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

| 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()`, 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.


---

[Full course index](/academy/llms.txt) · [Sitemap](/academy/sitemap.md)
