Skip to main content
Stripe sends webhook events to this endpoint so your app can create/update subscriptions and log billing history even if the user closes the browser. The handler verifies Stripe’s signature, processes the event, and updates Supabase using a service client.
  • Endpoint: POST /api/webhooks/stripe
  • File Location: src/app/api/webhooks/stripe/route.ts
  • Authentication: Stripe signature (stripe-signature header)
  • Requires: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, Supabase service client (SUPABASE_SECRET_KEY)

Headers & Payload

stripe-signature
string
required
HMAC signature Stripe uses to prove the event originated from your account. Verified with stripe.webhooks.constructEvent.
raw
string
required
Raw request body (no JSON parsing before verification). The handler reads the text stream directly, then constructs the event.
Never JSON.parse the body before signature verification. Reading/parsing the body changes the payload and the signature check will fail.

Events Handled

EventAction
customer.subscription.createdUpserts a row in user_subscriptions with plan, billing cycle, trial dates, and metadata
customer.subscription.updatedUpdates plan changes, cancellation flags, billing periods, and feedback fields
customer.subscription.deletedMarks the subscription as cancelled and timestamps canceled_at
invoice.payment_succeededInserts a row in payment_history (amount, currency, status, invoice URL, payment intent)
invoice.payment_failedLogs failed payments so you can notify the user or retry
Metadata added in the checkout session (user_id, user_email) links each event back to the correct Supabase user.

Database Side Effects

  • user_subscriptions: Stores Stripe customer/subscription IDs, plan, billing cycle, current/next period, cancellation info, trial info, and feedback.
  • payment_history: Stores Stripe invoice + payment intent IDs, amount (in cents), currency, status, hosted invoice URL, and timestamps.
Both tables rely on Row Level Security (RLS), so the webhook uses createServiceClient() (service key) to bypass RLS safely.

Implementation Snippet

src/app/api/webhooks/stripe/route.ts (trimmed)
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { stripe } from "@/lib/payments/stripe";
import { createServiceClient } from "@/lib/supabase/server";
import Stripe from "stripe";

const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(req: NextRequest) {
  const body = await req.text();
  const headersList = await headers();
  const sig = headersList.get("stripe-signature") as string;

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(body, sig, endpointSecret);
  } catch (err: any) {
    console.error(`Webhook signature verification failed.`, err.message);
    return NextResponse.json(
      { error: "Webhook signature verification failed" },
      { status: 400 }
    );
  }

  try {
    switch (event.type) {
      case "customer.subscription.created":
        await handleSubscriptionCreated(event.data.object as Stripe.Subscription);
        break;
      case "customer.subscription.updated":
        await handleSubscriptionUpdate(event.data.object as Stripe.Subscription);
        break;
      case "customer.subscription.deleted":
        await handleSubscriptionCancellation(event.data.object as Stripe.Subscription);
        break;
      case "invoice.payment_succeeded":
        await handlePaymentSuccess(event.data.object as Stripe.Invoice);
        break;
      case "invoice.payment_failed":
        await handlePaymentFailure(event.data.object as Stripe.Invoice);
        break;
      default:
        console.log(`Unhandled event type ${event.type}`);
    }

    return NextResponse.json({ received: true });
  } catch (error) {
    console.error("Error processing webhook:", error);
    return NextResponse.json(
      { error: "Error processing webhook" },
      { status: 500 }
    );
  }
}
Each helper retrieves the Supabase service client, pulls the user ID from subscription.metadata.user_id, and performs an upsert/update/insert with full timestamp handling.

Setup Steps

1

Expose endpoint

Deploy the Next.js API route (/api/webhooks/stripe) to Vercel. Ensure the route is reachable over HTTPS (Stripe requires TLS).
2

Set environment variables

.env
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
SUPABASE_SECRET_KEY=sbp_...
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
Redeploy after adding or changing secrets.
3

Configure Stripe Dashboard

  1. Stripe Dashboard → Developers → Webhooks
  2. Toggle Live mode (top-right)
  3. Click Add endpoint
  4. URL: https://yourdomain.com/api/webhooks/stripe
  5. Select the five events listed above
  6. Save and copy the signing secret (whsec_...)
4

Add preview/test endpoints (optional)

Create a second endpoint pointing to your preview domain or use Stripe CLI for local testing (stripe listen --forward-to localhost:3000/api/webhooks/stripe).

Testing

  • Local (Stripe CLI)
  • Live mode
stripe listen --forward-to localhost:3000/api/webhooks/stripe

# Simulate subscription creation
stripe trigger customer.subscription.created

# Simulate successful invoices
stripe trigger invoice.payment_succeeded
Watch the terminal for ✔ Response: 200 and confirm Supabase tables updated.

Troubleshooting

Cause: Payload mutated before verification, wrong signing secret, or missing stripe-signature header.Fix: Ensure you read req.text() once, never call JSON.parse before verification, and double-check STRIPE_WEBHOOK_SECRET.
Cause: Checkout session or portal event was created without metadata.user_id.Fix: Ensure /api/checkout_sessions adds metadata and Customer Portal actions always originate from existing subscriptions (the handler fetches metadata from the subscription record).
Cause: invoice.payment_succeeded didn’t include a subscription ID (rare on manual invoices).Fix: The handler already fetches the subscription via stripe.subscriptions.retrieve. Ensure the invoice is linked to a subscription, or ignore the event if it’s unrelated.
Cause: Supabase service client lacks permissions or table names differ.Fix: Confirm SUPABASE_SECRET_KEY is set and RLS policies match the schema created in supabase/migrations/20240101000000_create_user_profiles.sql.