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