> ## Documentation Index
> Fetch the complete documentation index at: https://docs.getsabo.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Stripe Webhooks

> Process Stripe subscription + billing events and sync Supabase tables

<Info>
  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.
</Info>

* **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

<ParamField header="stripe-signature" type="string" required>
  HMAC signature Stripe uses to prove the event originated from your account. Verified with `stripe.webhooks.constructEvent`.
</ParamField>

<ParamField body="raw" type="string" required>
  Raw request body (no JSON parsing before verification). The handler reads the text stream directly, then constructs the event.
</ParamField>

<Warning>
  Never `JSON.parse` the body before signature verification. Reading/parsing the body changes the payload and the signature check will fail.
</Warning>

## Events Handled

| Event                           | Action                                                                                     |
| ------------------------------- | ------------------------------------------------------------------------------------------ |
| `customer.subscription.created` | Upserts a row in `user_subscriptions` with plan, billing cycle, trial dates, and metadata  |
| `customer.subscription.updated` | Updates plan changes, cancellation flags, billing periods, and feedback fields             |
| `customer.subscription.deleted` | Marks the subscription as cancelled and timestamps `canceled_at`                           |
| `invoice.payment_succeeded`     | Inserts a row in `payment_history` (amount, currency, status, invoice URL, payment intent) |
| `invoice.payment_failed`        | Logs 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

```ts src/app/api/webhooks/stripe/route.ts (trimmed) theme={null}
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

<Steps>
  <Step title="Expose endpoint">
    Deploy the Next.js API route (`/api/webhooks/stripe`) to Vercel. Ensure the route is reachable over HTTPS (Stripe requires TLS).
  </Step>

  <Step title="Set environment variables">
    ```bash .env theme={null}
    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.
  </Step>

  <Step title="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_...`)
  </Step>

  <Step title="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`).
  </Step>
</Steps>

## Testing

<Tabs>
  <Tab title="Local (Stripe CLI)">
    ```bash theme={null}
    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.
  </Tab>

  <Tab title="Live mode">
    1. Complete a real checkout using the [Create Checkout Session](/api-reference/endpoint/checkout-sessions) endpoint.
    2. Stripe will emit `customer.subscription.created`.
    3. In Stripe Dashboard → Webhooks → Your endpoint → Logs, verify you see `2xx` responses.
    4. Query Supabase to confirm data:

    ```sql theme={null}
    SELECT * FROM user_subscriptions WHERE user_id = 'uuid';
    SELECT * FROM payment_history WHERE user_id = 'uuid';
    ```
  </Tab>
</Tabs>

## Troubleshooting

<AccordionGroup>
  <Accordion title="400 · Signature verification failed">
    **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`.
  </Accordion>

  <Accordion title="No user ID in metadata">
    **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).
  </Accordion>

  <Accordion title="Payment records missing">
    **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.
  </Accordion>

  <Accordion title="Webhook succeeds but database unchanged">
    **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`.
  </Accordion>
</AccordionGroup>

## Related Documentation

<CardGroup cols={2}>
  <Card title="Create Checkout Session" icon="shopping-cart" href="/api-reference/endpoint/checkout-sessions">
    Generates the Checkout session that seeds webhook metadata.
  </Card>

  <Card title="Customer Portal" icon="user" href="/api-reference/endpoint/customer-portal">
    Lets users manage subscriptions; portal actions also hit this webhook.
  </Card>

  <Card title="Payments with Stripe" icon="credit-card" href="/core-features/payments-with-stripe">
    End-to-end Stripe setup, product configuration, and webhook forwarding tips.
  </Card>

  <Card title="Environment Variables" icon="code" href="/core-features/environment-variables">
    Reference for Stripe and Supabase secrets required by this endpoint.
  </Card>
</CardGroup>
