Back to catalog

Integrating Stripe Payments

Complete guide for integrating Stripe payments (subscriptions or one-time) with Convex + Next.js. Includes user interviews, API setup, webhook configuration, testing phases, and production deployment. Use this skill when Adding payment functionality to a Convex + Next.js app

  • Category: discovered
  • Tags: github, community
  • Version: 1.0.0
  • Author: walid
  • Downloads: 0
  • Updated: 2025-11-22

Download the latest version (v1.0.0) from this page.

SKILL.md

---
name: Integrating Stripe Payments
description: Complete guide for integrating Stripe payments (subscriptions or one-time) with Convex + Next.js. Includes user interviews, API setup, webhook configuration, testing phases, and production deployment. Use this skill when Adding payment functionality to a Convex + Next.js app
---

# Integrating Stripe Payments

## Overview

This skill guides you through integrating Stripe payments into a Convex + Next.js application. It covers both subscription and one-time payment flows, with hosted Stripe checkout for simplicity and reliability.

**Use this skill when:**
- Adding payment functionality to a Convex + Next.js app
- Setting up subscription billing
- Processing one-time payments
- Need to avoid common Stripe + Convex integration mistakes

## Phase 1: Requirements Interview

Before starting implementation, gather these requirements from the user:

### Questions to Ask:

1. **Payment Type:**
   - Subscription (recurring billing)
   - One-time payment

2. **Backend Confirmation:**
   - Is this a Convex backend? (Required for this skill)
   - If not Convex, this skill won't apply

3. **Checkout Preference:**
   - Hosted Stripe Checkout (recommended - opens in new tab, less complex, more stable)
   - Embedded Checkout (stays on your site, more complex)

4. **Pricing Details:**
   - What's the price amount?
   - What currency?
   - For subscriptions: billing interval (monthly, every 6 months, yearly)?

5. **Product Information:**
   - Product name (e.g., "Premium Membership", "Founding Member")
   - What does the user get after payment?

### Recommended Approach

**Strongly recommend:** Hosted Stripe Checkout for subscriptions
- Less code complexity
- Better mobile support
- Stripe handles all payment UI
- More stable and secure
- Easier to test

## Phase 2: Installation & Dependencies

### 2.1 Install Stripe Package

```bash
npm install stripe
```

Note: For hosted checkout, you only need the server-side `stripe` package. No need for `@stripe/stripe-js` or `@stripe/react-stripe-js`.

### 2.2 Database Schema Updates

Add Stripe-related fields to your users table in `convex/schema.ts`:

```typescript
users: defineTable({
  // ... existing fields
  membershipStatus: v.optional(v.union(
    v.literal("free"),
    v.literal("premium"), // or your membership tier name
    v.literal("past_due") // For failed payments
  )),
  membershipExpiry: v.optional(v.number()), // Timestamp when membership expires
  stripeCustomerId: v.optional(v.string()), // Stripe customer ID
  stripeSubscriptionId: v.optional(v.string()), // Stripe subscription ID (for subscriptions)
})
  .index("by_stripe_customer", ["stripeCustomerId"])
  .index("by_stripe_subscription", ["stripeSubscriptionId"])
```

## Phase 3: API Keys Setup

### 3.1 Get Stripe API Keys

1. Go to [Stripe Dashboard](https://dashboard.stripe.com/)
2. Navigate to **Developers → API keys**
3. Copy your **Test mode** keys:
   - **Publishable key** (starts with `pk_test_...`)
   - **Secret key** (starts with `sk_test_...`)

### 3.2 Create Stripe Product & Price

1. In Stripe Dashboard, go to **Products**
2. Click **+ Add Product**
3. Enter product details:
   - Name: Your product name
   - Description: What the user gets
4. Add pricing:
   - **For one-time payments:** Set "One time" pricing
   - **For subscriptions:** Set "Recurring" and select interval
   - Enter price amount
5. Click **Save product**
6. Copy the **Price ID** (starts with `price_...`)

### 3.3 Set Environment Variables

**In Convex Dashboard** (Settings → Environment Variables):

```
STRIPE_SECRET_KEY=sk_test_your_secret_key_here
STRIPE_PRICE_ID=price_your_price_id_here
STRIPE_WEBHOOK_SECRET=(we'll get this in Phase 4)
```

**In `.env.local`** (for Next.js frontend):

```
NEXT_PUBLIC_SITE_URL=http://localhost:3000
```

## Phase 4: Code Implementation

### 4.1 Create Stripe Actions (`convex/stripe.ts`)

```typescript
"use node";

import Stripe from "stripe";
import { action } from "./_generated/server";
import { v } from "convex/values";
import { internal } from "./_generated/api";

/**
 * Create a Stripe Checkout Session
 */
export const createCheckoutSession = action({
  args: {
    clerkUserId: v.string(), // Or your auth user ID
    mode: v.optional(v.union(v.literal("subscription"), v.literal("payment"))),
  },
  handler: async (ctx, args): Promise<{ url: string | null; sessionId: string }> => {
    const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
      apiVersion: "2025-08-27.basil" as any,
    });

    // Get user from database
    const user: any = await ctx.runQuery(internal.stripeDb.getUserByClerkId, {
      clerkId: args.clerkUserId,
    });

    if (!user) {
      throw new Error("User not found");
    }

    // Create or retrieve Stripe customer
    let customerId: string | undefined = user.stripeCustomerId;
    if (!customerId) {
      const customer = await stripe.customers.create({
        email: user.email,
        name: user.name,
        metadata: {
          clerkUserId: args.clerkUserId,
          convexUserId: user._id,
        },
      });
      customerId = customer.id;

      // Update user with Stripe customer ID
      await ctx.runMutation(internal.stripeDb.updateStripeCustomerId, {
        userId: user._id,
        stripeCustomerId: customerId,
      });
    }

    const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000";
    const mode = args.mode || "subscription";

    // Create checkout session
    const session: Stripe.Checkout.Session = await stripe.checkout.sessions.create({
      customer: customerId,
      mode: mode, // "subscription" or "payment"
      line_items: [
        {
          price: process.env.STRIPE_PRICE_ID!,
          quantity: 1,
        },
      ],
      success_url: `${siteUrl}/checkout/return?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${siteUrl}`,
      metadata: {
        clerkUserId: args.clerkUserId,
        userId: user._id,
      },
    });

    return {
      url: session.url,
      sessionId: session.id,
    };
  },
});

/**
 * Get checkout session status (for return page)
 */
export const getCheckoutSessionStatus = action({
  args: {
    sessionId: v.string(),
  },
  handler: async (ctx, args) => {
    const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
      apiVersion: "2025-08-27.basil" as any,
    });

    const session = await stripe.checkout.sessions.retrieve(args.sessionId);

    return {
      status: session.status,
      customerEmail: session.customer_details?.email,
      paymentStatus: session.payment_status,
    };
  },
});

/**
 * Create Customer Portal Session
 * Essential for letting users manage their subscriptions, payment methods, and invoices
 */
export const createCustomerPortalSession = action({
  args: {
    clerkUserId: v.string(),
  },
  handler: async (ctx, args): Promise<{ url: string }> => {
    const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
      apiVersion: "2025-08-27.basil" as any,
    });

    // Get user from database
    const user: any = await ctx.runQuery(internal.stripeDb.getUserByClerkId, {
      clerkId: args.clerkUserId,
    });

    if (!user || !user.stripeCustomerId) {
      throw new Error("User not found or has no Stripe customer ID");
    }

    const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000";

    // Create portal session
    const session = await stripe.billingPortal.sessions.create({
      customer: user.stripeCustomerId,
      return_url: `${siteUrl}/dashboard`,
    });

    return {
      url: session.url,
    };
  },
});
```

### 4.2 Create Database Helpers (`convex/stripeDb.ts`)

```typescript
import { internalMutation, internalQuery } from "./_generated/server";
import { v } from "convex/values";

/**
 * Internal query to get user by Clerk ID
 */
export const getUserByClerkId = internalQuery({
  args: {
    clerkId: v.string(),
  },
  handler: async (ctx, args) => {
    const user = await ctx.db
      .query("users")
      .withIndex("by_clerk_id", (q) => q.eq("clerkId", args.clerkId))
      .unique();

    return user;
  },
});

/**
 * Internal mutation to update user's Stripe customer ID
 */
export const updateStripeCustomerId = internalMutation({
  args: {
    userId: v.id("users"),
    stripeCustomerId: v.string(),
  },
  handler: async (ctx, args) => {
    await ctx.db.patch(args.userId, {
      stripeCustomerId: args.stripeCustomerId,
    });
  },
});

/**
 * Internal mutation to update user's membership status after successful payment
 */
export const updateMembershipStatus = internalMutation({
  args: {
    clerkUserId: v.string(),
    stripeSubscriptionId: v.string(), // For subscriptions
    currentPeriodEnd: v.number(), // Timestamp when current period ends
  },
  handler: async (ctx, args) => {
    const user = await ctx.db
      .query("users")
      .withIndex("by_clerk_id", (q) => q.eq("clerkId", args.clerkUserId))
      .unique();

    if (!user) {
      throw new Error("User not found");
    }

    await ctx.db.patch(user._id, {
      membershipStatus: "premium",
      membershipExpiry: args.currentPeriodEnd * 1000, // Convert to milliseconds
      stripeSubscriptionId: args.stripeSubscriptionId,
    });

    return user._id;
  },
});

/**
 * Internal mutation to cancel user's membership
 */
export const cancelMembership = internalMutation({
  args: {
    stripeSubscriptionId: v.string(),
  },
  handler: async (ctx, args) => {
    const user = await ctx.db
      .query("users")
      .withIndex("by_stripe_subscription", (q) =>
        q.eq("stripeSubscriptionId", args.stripeSubscriptionId)
      )
      .unique();

    if (!user) {
      console.error("User not found for subscription:", args.stripeSubscriptionId);
      return null;
    }

    await ctx.db.patch(user._id, {
      membershipStatus: "free",
      membershipExpiry: undefined,
      stripeSubscriptionId: undefined,
    });

    return user._id;
  },
});

/**
 * Internal mutation to handle payment failure
 */
export const handlePaymentFailure = internalMutation({
  args: {
    stripeSubscriptionId: v.string(),
    attemptCount: v.number(),
  },
  handler: async (ctx, args) => {
    const user = await ctx.db
      .query("users")
      .withIndex("by_stripe_subscription", (q) =>
        q.eq("stripeSubscriptionId", args.stripeSubscriptionId)
      )
      .unique();

    if (!user) {
      console.error("User not found for subscription:", args.stripeSubscriptionId);
      return null;
    }

    // You can implement grace period logic here
    // For example, keep access for 3 failed attempts
    if (args.attemptCount >= 3) {
      await ctx.db.patch(user._id, {
        membershipStatus: "past_due",
      });
    }

    return user._id;
  },
});
```

### 4.3 Create Webhook Handler (`convex/http.ts`)

⚠️ **CRITICAL: Use `.convex.site` domain for webhooks, NOT `.convex.cloud`**

```typescript
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";

const http = httpRouter();

// Stripe webhook handler
http.route({
  path: "/stripe/webhook",
  method: "POST",
  handler: httpAction(async (ctx, request: Request) => {
    const Stripe = (await import("stripe")).default;
    const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
      apiVersion: "2025-08-27.basil" as any,
    });

    const body = await request.text();
    const sig = request.headers.get("stripe-signature");

    if (!sig) {
      return new Response(JSON.stringify({ error: "No signature" }), {
        status: 400,
      });
    }

    try {
      // ⚠️ CRITICAL: Use constructEventAsync (NOT constructEvent)
      const event = await stripe.webhooks.constructEventAsync(
        body,
        sig,
        process.env.STRIPE_WEBHOOK_SECRET!
      );

      // Handle the event
      switch (event.type) {
        case "checkout.session.completed": {
          const session = event.data.object as any;

          const clerkUserId = session.metadata?.clerkUserId;

          // Handle subscription checkout
          if (session.mode === "subscription") {
            const subscriptionId = session.subscription as string;

            if (!clerkUserId || !subscriptionId) {
              console.error("Missing clerkUserId or subscriptionId");
              break;
            }

            // ⚠️ CRITICAL: current_period_end is in subscription.items.data[0]
            const subscription: any = await stripe.subscriptions.retrieve(subscriptionId);
            const currentPeriodEnd = subscription.items?.data?.[0]?.current_period_end;

            if (!currentPeriodEnd) {
              console.error("No current_period_end found");
              break;
            }

            // Update user membership
            const { internal } = await import("./_generated/api.js");
            await ctx.runMutation(internal.stripeDb.updateMembershipStatus, {
              clerkUserId,
              stripeSubscriptionId: subscriptionId,
              currentPeriodEnd,
            });

            console.log(`✅ Membership activated for user: ${clerkUserId}`);
          }

          // Handle one-time payment checkout
          if (session.mode === "payment") {
            // For one-time payments, you might want different logic
            console.log(`✅ One-time payment completed for user: ${clerkUserId}`);
          }

          break;
        }

        case "customer.subscription.updated": {
          // Handle subscription renewal/update
          const subscription = event.data.object as any;
          const clerkUserId = subscription.metadata?.clerkUserId;

          if (!clerkUserId) {
            console.error("Missing clerkUserId in subscription metadata");
            break;
          }

          const currentPeriodEnd = subscription.items?.data?.[0]?.current_period_end;

          if (!currentPeriodEnd) {
            console.error("No current_period_end found");
            break;
          }

          // Update membership expiry (handles renewals)
          const { internal } = await import("./_generated/api.js");
          await ctx.runMutation(internal.stripeDb.updateMembershipStatus, {
            clerkUserId,
            stripeSubscriptionId: subscription.id,
            currentPeriodEnd,
          });

          console.log(`✅ Subscription updated for user: ${clerkUserId}`);
          break;
        }

        case "customer.subscription.deleted": {
          // Handle subscription cancellation
          const subscription = event.data.object as any;

          const { internal } = await import("./_generated/api.js");
          await ctx.runMutation(internal.stripeDb.cancelMembership, {
            stripeSubscriptionId: subscription.id,
          });

          console.log(`✅ Subscription canceled: ${subscription.id}`);
          break;
        }

        case "invoice.payment_failed": {
          // Handle failed payment
          const invoice = event.data.object as any;
          const subscriptionId = invoice.subscription;

          if (!subscriptionId) {
            console.error("No subscription ID in failed invoice");
            break;
          }

          const attemptCount = invoice.attempt_count || 0;

          const { internal } = await import("./_generated/api.js");
          await ctx.runMutation(internal.stripeDb.handlePaymentFailure, {
            stripeSubscriptionId: subscriptionId,
            attemptCount,
          });

          console.log(`⚠️ Payment failed for subscription: ${subscriptionId}, attempt: ${attemptCount}`);
          break;
        }

        case "invoice.paid": {
          // Confirm successful payment (handles renewals)
          const invoice = event.data.object as any;
          const subscriptionId = invoice.subscription;

          if (!subscriptionId) {
            break; // One-time invoice, not subscription
          }

          const subscription: any = await stripe.subscriptions.retrieve(subscriptionId);
          const clerkUserId = subscription.metadata?.clerkUserId;
          const currentPeriodEnd = subscription.items?.data?.[0]?.current_period_end;

          if (!clerkUserId || !currentPeriodEnd) {
            console.error("Missing data for invoice.paid event");
            break;
          }

          const { internal } = await import("./_generated/api.js");
          await ctx.runMutation(internal.stripeDb.updateMembershipStatus, {
            clerkUserId,
            stripeSubscriptionId: subscriptionId,
            currentPeriodEnd,
          });

          console.log(`✅ Invoice paid for user: ${clerkUserId}`);
          break;
        }
      }

      return new Response(JSON.stringify({ received: true }), {
        status: 200,
      });
    } catch (err) {
      console.error("Webhook error:", err);
      return new Response(
        JSON.stringify({ error: err instanceof Error ? err.message : "Webhook error" }),
        { status: 400 }
      );
    }
  }),
});

export default http;
```

### 4.4 Frontend Integration

#### Checkout Button

On your frontend, create a button that calls the `createCheckoutSession` action:

```tsx
"use client";

import { useAction } from "convex/react";
import { api } from "@/convex/_generated/api";
import { useUser } from "@clerk/nextjs";
import { useState } from "react";

export function UpgradeButton() {
  const { user } = useUser();
  const createCheckoutSession = useAction(api.stripe.createCheckoutSession);
  const [loading, setLoading] = useState(false);

  const handleUpgrade = async () => {
    if (!user) return;

    setLoading(true);
    try {
      const result = await createCheckoutSession({
        clerkUserId: user.id,
        mode: "subscription", // or "payment" for one-time
      });

      if (result.url) {
        window.open(result.url, "_blank");
      }
    } catch (error) {
      console.error("Error creating checkout session:", error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <button onClick={handleUpgrade} disabled={loading}>
      {loading ? "Loading..." : "Upgrade to Premium"}
    </button>
  );
}
```

#### Customer Portal Button

Allow users to manage their subscription, payment methods, and billing:

```tsx
"use client";

import { useAction } from "convex/react";
import { api } from "@/convex/_generated/api";
import { useUser } from "@clerk/nextjs";
import { useState } from "react";

export function ManageBillingButton() {
  const { user } = useUser();
  const createPortalSession = useAction(api.stripe.createCustomerPortalSession);
  const [loading, setLoading] = useState(false);

  const handleManageBilling = async () => {
    if (!user) return;

    setLoading(true);
    try {
      const result = await createPortalSession({
        clerkUserId: user.id,
      });

      if (result.url) {
        window.open(result.url, "_blank");
      }
    } catch (error) {
      console.error("Error creating portal session:", error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <button onClick={handleManageBilling} disabled={loading}>
      {loading ? "Loading..." : "Manage Billing"}
    </button>
  );
}
```

### 4.5 Return Page (`app/checkout/return/page.tsx`)

See `resources/return-page-example.tsx` for full implementation with success/error states.

## Phase 5: Webhook Setup & Testing

### 5.1 Get Your Convex HTTP Actions URL

⚠️ **CRITICAL: Use the `.convex.site` domain**

1. Go to Convex Dashboard → **Settings**
2. Find your deployment URL
3. Your webhook URL will be: `https://your-deployment.convex.site/stripe/webhook`
   - ❌ NOT `.convex.cloud`
   - ✅ USE `.convex.site`

### 5.2 Create Webhook in Stripe Dashboard

1. Go to **Developers → Webhooks** in Stripe Dashboard
2. Click **+ Add endpoint**
3. Enter webhook URL: `https://your-deployment.convex.site/stripe/webhook`
4. Select events to listen for:
   - `checkout.session.completed` (required - initial payment)
   - `customer.subscription.updated` (required - renewa