| 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:
Payment Type:
- Subscription (recurring billing)
- One-time payment
Backend Confirmation:
- Is this a Convex backend? (Required for this skill)
- If not Convex, this skill won't apply
Checkout Preference:
- Hosted Stripe Checkout (recommended - opens in new tab, less complex, more stable)
- Embedded Checkout (stays on your site, more complex)
Pricing Details:
- What's the price amount?
- What currency?
- For subscriptions: billing interval (monthly, every 6 months, yearly)?
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
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:
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
- Go to Stripe Dashboard
- Navigate to Developers → API keys
- Copy your Test mode keys:
- Publishable key (starts with
pk_test_...) - Secret key (starts with
sk_test_...)
- Publishable key (starts with
3.2 Create Stripe Product & Price
- In Stripe Dashboard, go to Products
- Click + Add Product
- Enter product details:
- Name: Your product name
- Description: What the user gets
- Add pricing:
- For one-time payments: Set "One time" pricing
- For subscriptions: Set "Recurring" and select interval
- Enter price amount
- Click Save product
- 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)
"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)
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
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:
"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:
"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
- Go to Convex Dashboard → Settings
- Find your deployment URL
- Your webhook URL will be:
https://your-deployment.convex.site/stripe/webhook- ❌ NOT
.convex.cloud - ✅ USE
.convex.site
- ❌ NOT
5.2 Create Webhook in Stripe Dashboard
- Go to Developers → Webhooks in Stripe Dashboard
- Click + Add endpoint
- Enter webhook URL:
https://your-deployment.convex.site/stripe/webhook - Select events to listen for:
checkout.session.completed(required - initial payment)customer.subscription.updated(required - renewals & updates)customer.subscription.deleted(required - cancellations)invoice.payment_failed(required - failed payments)invoice.paid(recommended - successful renewals)
- Click Add endpoint
- Copy the Signing secret (starts with
whsec_...) - Add to Convex environment:
STRIPE_WEBHOOK_SECRET=whsec_...
5.3 Test with Stripe CLI (Optional but Recommended)
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Login
stripe login
# Forward webhooks to your Convex endpoint
stripe listen --forward-to https://your-deployment.convex.site/stripe/webhook
Phase 6: Dev Mode Testing
6.1 Test Checklist
- Start your app:
npm run dev - Click upgrade/checkout button
- Verify Stripe checkout page opens in new tab
- Use Stripe test card:
- Card:
4242 4242 4242 4242 - Expiry: Any future date
- CVC: Any 3 digits
- ZIP: Any 5 digits
- Card:
- Complete payment
- Verify redirect to success page
- Check Convex Dashboard → Data → users table
- Confirm user has updated membership fields
- Check Stripe Dashboard → Webhooks → Events
- Verify webhook was received successfully
6.2 Common Issues & Solutions
Issue: Webhook not receiving events
- Fix: Confirm you're using
.convex.sitenot.convex.cloud - Fix: Verify webhook secret is set correctly in Convex env vars
Issue: SubtleCryptoProvider cannot be used in a synchronous context
- Fix: Use
constructEventAsyncnotconstructEvent
Issue: Membership status not updating
- Fix: Check
current_period_endis accessed fromsubscription.items.data[0] - Fix: Verify
clerkUserIdis in checkout session metadata
Phase 7: Customer Portal Configuration
The Customer Portal is essential for any SaaS product. It allows users to self-manage their subscriptions without contacting support.
7.1 Configure Customer Portal in Dashboard
- Go to Customer Portal Settings in Stripe Dashboard
- Business information
- Add your logo, icon, and brand colors
- Add support email and phone number
- Add Terms of Service and Privacy Policy URLs
- Features to enable:
- ✅ Update payment method - Let customers add/remove cards
- ✅ Cancel subscriptions - Choose immediate or end-of-period cancellation
- ✅ Update subscription - Allow upgrades/downgrades (if you have multiple tiers)
- ✅ Invoice history - Let customers download past invoices
- ✅ Customer information - Allow email/address updates
- Click Save
7.2 What Customers Can Do in the Portal
With the Customer Portal, your customers can:
- View current subscription and billing cycle
- Update payment methods (add/remove cards)
- Cancel or resume subscriptions
- View and download all invoices
- Update billing information
- See payment history
This means less support work for you! Most billing questions can be self-served.
7.3 Portal Best Practices
Where to place the Portal button:
- In account/settings page (always visible)
- In subscription status displays
- In email receipts (Stripe adds this automatically)
When to show the Portal button:
- Only show to users with
stripeCustomerId(i.e., users who have subscribed)
Example conditional rendering:
{user.stripeCustomerId && <ManageBillingButton />}
Phase 8: Payment Failure Handling & Revenue Recovery
~9-15% of subscription payments fail initially, but most are recoverable. Proper handling is critical for revenue.
8.1 Enable Smart Retries
Smart Retries use AI to determine the best time to retry failed payments.
In Stripe Dashboard:
- Go to Revenue Recovery → Retries
- Toggle on Smart Retries
- Configure retry settings:
- Number of retries: 4-8 retries recommended
- Duration: 2-4 weeks recommended
- Configure what happens after final retry:
- Recommended: Mark subscription as unpaid (keeps subscription, stops invoicing)
- Alternative: Cancel subscription
- Alternative: Leave past_due (keeps invoicing, may annoy customers)
- Click Save
8.2 Why Smart Retries Matter
- Success rate: 15-25% of failed payments succeed on retry
- Revenue recovery: Can recover thousands per month for mid-size SaaS
- AI-powered: Retries at optimal times (e.g., after payday for debit cards)
- No work required: Fully automated once enabled
8.3 Handle Failed Payments in Your App
Your app should respond to payment failures:
// Already implemented in convex/stripeDb.ts!
export const handlePaymentFailure = internalMutation({
args: {
stripeSubscriptionId: v.string(),
attemptCount: v.number(),
},
handler: async (ctx, args) => {
// Grace period logic: keep access for 3 attempts
if (args.attemptCount >= 3) {
await ctx.db.patch(user._id, {
membershipStatus: "past_due",
});
}
},
});
User Experience Recommendations:
- Attempts 1-2: Don't revoke access, send gentle reminder email
- Attempts 3-4: Revoke access, show "Payment Failed" banner in app
- Final retry: Send "Subscription at risk of cancellation" email
8.4 Enable Automated Emails (Recommended)
- Go to Billing → Revenue recovery → Emails
- Enable these emails:
- ✅ Payment failed - Sent immediately when payment fails
- ✅ Card expiring soon - Sent 7-15 days before expiry
- ✅ Update payment method - Sent when card needs updating
- Customize email templates with your branding
- Click Save
Why this matters: Automated emails recover 5-10% of failed payments without any manual work.
8.5 Monitor Failed Payments
In your Dashboard:
- Go to Billing → Revenue Recovery
- View recovery rate and revenue recovered
- See which customers have failing payments
Set up alerts:
- For high-value subscriptions (>$100/month), notify your sales team of failures
- Use webhooks to send Slack notifications for VIP customer failures
Phase 9: Comprehensive Testing Guide
Thorough testing prevents production issues and lost revenue.
9.1 Local Webhook Testing with Stripe CLI
Install Stripe CLI:
# macOS
brew install stripe/stripe-cli/stripe
# Windows (with Scoop)
scoop install stripe
# Or download from https://stripe.com/docs/stripe-cli
Forward webhooks to Convex:
# Login first
stripe login
# Forward webhooks to your Convex deployment
stripe listen --forward-to https://your-deployment.convex.site/stripe/webhook
# You'll see a webhook signing secret - add this to Convex env vars temporarily for testing
Test specific events:
# Test successful subscription creation
stripe trigger checkout.session.completed
# Test subscription renewal
stripe trigger customer.subscription.updated
# Test failed payment
stripe trigger invoice.payment_failed
# Test cancellation
stripe trigger customer.subscription.deleted
9.2 Test Cards & Scenarios
Use these test card numbers in test mode only:
| Card Number | Scenario | Use Case |
|---|---|---|
4242 4242 4242 4242 |
Succeeds | Normal successful payment |
4000 0025 0000 3155 |
Requires authentication | Test 3D Secure flow |
4000 0000 0000 9995 |
Always declines | Test payment failure handling |
4000 0000 0000 0341 |
Attaching requires auth | Test payment method updates |
4000 0082 6000 0000 |
Expires in current year | Test expiring card emails |
Expiry & CVC: Any future date and any 3-digit CVC work for test cards.
9.3 Test Checklist (Before Production)
Test all critical flows:
Initial Subscription Flow:
- User can click "Upgrade" and reach Stripe Checkout
- Test card
4242...successfully creates subscription - User redirects to success page after payment
-
membershipStatusupdates to "premium" in Convex -
membershipExpiryis set correctly (1 month from now for monthly) - Webhook
checkout.session.completedreceived and processed
Customer Portal Flow:
- "Manage Billing" button works for subscribed users
- User can view subscription details in portal
- User can update payment method
- User can cancel subscription
- Cancellation triggers
customer.subscription.deletedwebhook -
membershipStatusupdates to "free" after cancellation
Payment Failure Flow:
- Use test card
4000 0000 0000 9995to trigger failure -
invoice.payment_failedwebhook received -
handlePaymentFailuremutation runs correctly - User sees appropriate message in app after 3 failures
- Smart Retries are scheduled correctly
Renewal Flow:
- Use Test Clocks to simulate time passage (see below)
- Subscription renews automatically after 1 month
-
customer.subscription.updatedorinvoice.paidwebhook fires -
membershipExpiryextends by another month
9.4 Test Clocks (Advanced - Simulate Time)
Test Clocks let you simulate subscription renewals without waiting weeks/months.
Create Test Clock:
- Go to Workbench → Test Clocks
- Click Create test clock
- Set start time to "now"
- Create customer and subscription using this test clock
- Advance time by 1 month to test renewal
- Advance by 3 months to test failed payment retries
With Test Clocks you can test:
- Annual subscription renewals (without waiting 1 year!)
- Trial expiration (without waiting 14 days)
- Failed payment retry schedules
- Proration calculations
9.5 Monitor Webhook Delivery
In Stripe Dashboard:
- Go to Developers → Webhooks
- Click on your webhook endpoint
- View Event deliveries tab
- Check for:
- ✅ All events have 200 status (success)
- ❌ Any 400/500 errors (your webhook failed)
- ⏱️ Response times (should be <2 seconds)
Debug failed webhooks:
- Click on failed event to see error message
- Use Convex logs to see what went wrong
- Use "Resend" button to retry webhook
Phase 10: Production Deployment
10.1 Switch to Live Mode
- Get Live API Keys from Stripe Dashboard (toggle to Live mode)
- Create Production Product & Price in Live mode
- Update Convex Production Environment Variables:
STRIPE_SECRET_KEY=sk_live_... STRIPE_PRICE_ID=price_live_... STRIPE_WEBHOOK_SECRET=whsec_live_... - Update Next.js Environment:
NEXT_PUBLIC_SITE_URL=https://yourdomain.com
10.2 Create Production Webhook
- In Stripe Dashboard (Live mode) → Webhooks
- Add endpoint:
https://your-prod-deployment.convex.site/stripe/webhook - Select all required events (same as test mode)
- Copy new signing secret
- Update
STRIPE_WEBHOOK_SECRETin Convex production env
10.3 Configure Customer Portal (Live Mode)
- Go to Customer Portal Settings (Live mode)
- Configure same settings as test mode
- Add your production URLs and branding
- Enable desired features
- Click Save
10.4 Enable Smart Retries (Live Mode)
- Go to Revenue Recovery → Retries (Live mode)
- Enable Smart Retries with same settings as test mode
- Enable automated emails for payment failures
- Click Save
10.5 Production Test
Test with real payment method (refund immediately after):
- Complete successful subscription purchase
- Verify webhook delivery in Stripe Dashboard → Webhooks → Event deliveries
- Check user membership updates in production database (Convex Dashboard)
- Test Customer Portal access (update payment method, view invoices)
- Test cancellation flow (cancel and verify webhook + database update)
- Refund the test payment in Stripe Dashboard
Monitor for 24-48 hours:
- Check webhook success rate (should be 100%)
- Monitor error logs in Convex
- Check first real customer payments process correctly
Phase 11: Production Best Practices & Webhook Reliability
11.1 Webhook Reliability Patterns
Idempotency - Prevent Duplicate Processing
Webhooks may be sent multiple times. Your handler must be idempotent:
// Add this to convex/stripeDb.ts
export const processedWebhookEvents = defineTable({
eventId: v.string(), // Stripe event ID
processedAt: v.number(),
}).index("by_event_id", ["eventId"]);
// Check before processing webhook
export const isEventProcessed = internalQuery({
args: { eventId: v.string() },
handler: async (ctx, args) => {
const existing = await ctx.db
.query("processedWebhookEvents")
.withIndex("by_event_id", (q) => q.eq("eventId", args.eventId))
.unique();
return !!existing;
},
});
export const markEventProcessed = internalMutation({
args: { eventId: v.string() },
handler: async (ctx, args) => {
await ctx.db.insert("processedWebhookEvents", {
eventId: args.eventId,
processedAt: Date.now(),
});
},
});
Update webhook handler to use idempotency:
// In convex/http.ts, before switch statement:
const { internal } = await import("./_generated/api.js");
// Check if already processed
const isProcessed = await ctx.runQuery(internal.stripeDb.isEventProcessed, {
eventId: event.id,
});
if (isProcessed) {
console.log(`Event ${event.id} already processed, skipping`);
return new Response(JSON.stringify({ received: true }), { status: 200 });
}
// ... handle event with switch statement ...
// After successful processing, mark as processed
await ctx.runMutation(internal.stripeDb.markEventProcessed, {
eventId: event.id,
});
Event Ordering - Don't Assume Order
Stripe doesn't guarantee event order. Handle events independently:
// ❌ DON'T rely on event order
// Assuming subscription.updated comes before invoice.paid
// ✅ DO handle each event independently
// Each event should have enough data to process standalone
Best practice: Always fetch the latest subscription state from Stripe if you need current data:
case "invoice.payment_failed": {
const invoice = event.data.object;
// Fetch current subscription state (don't assume from previous events)
const subscription = await stripe.subscriptions.retrieve(invoice.subscription);
// Now you have accurate current state
}
11.2 Monitoring & Alerting
Set Up Monitoring
Webhook health checks:
// Create a simple health endpoint in convex/http.ts
http.route({
path: "/health",
method: "GET",
handler: httpAction(async () => {
return new Response(JSON.stringify({ status: "ok" }), { status: 200 });
}),
});
Monitor in Stripe Dashboard:
- Go to Developers → Webhooks daily (first week of production)
- Check "Event deliveries" for any failures
- Set up email notifications for webhook failures
Monitor in Convex:
- Check logs for any Stripe-related errors
- Monitor subscription creation/update rates
- Track failed payment rates
Set Up Alerts
Critical alerts to implement:
- Webhook failure rate > 1%
- No subscriptions created in 24 hours (if you usually get signups)
- Failed payment rate > 15%
- Subscription cancellation spike (>2x normal rate)
11.3 Data Consistency
Always sync from Stripe as source of truth:
// Good: Periodically sync subscription status from Stripe
export const syncSubscriptionStatus = internalMutation({
args: {
clerkUserId: v.string(),
},
handler: async (ctx, args) => {
const user = await ctx.db
.query("users")
.withIndex("by_clerk_id", (q) => q.eq("clerkId", args.clerkUserId))
.unique();
if (!user || !user.stripeSubscriptionId) return;
// Fetch current state from Stripe
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const subscription = await stripe.subscriptions.retrieve(user.stripeSubscriptionId);
// Update database to match Stripe
const currentPeriodEnd = subscription.items?.data?.[0]?.current_period_end;
const status = subscription.status === "active" ? "premium" : "free";
await ctx.db.patch(user._id, {
membershipStatus: status,
membershipExpiry: currentPeriodEnd ? currentPeriodEnd * 1000 : undefined,
});
},
});
Run this sync:
- When user logs in (to ensure accurate status)
- Daily via cron job (for all active subscriptions)
- When displaying billing information
11.4 Security Best Practices
Protect your API keys:
- Never commit
STRIPE_SECRET_KEYto git - Use different keys for dev/prod
- Rotate keys every 6 months (or immediately if compromised)
Verify webhook signatures:
- Always use
constructEventAsync(already implemented) - Never trust webhook data without signature verification
- Keep
STRIPE_WEBHOOK_SECRETsecure
Limit webhook endpoint access:
- Only accept POST requests (already implemented)
- Add rate limiting if you get webhook spam
- Monitor for suspicious activity
11.5 Common Production Issues & Solutions
Issue: Webhooks stopped working
- Check if Convex deployment URL changed
- Verify
STRIPE_WEBHOOK_SECRETis correct - Check webhook endpoint is enabled in Stripe Dashboard
- Look for errors in Stripe → Webhooks → Event deliveries
Issue: Subscriptions not updating after renewal
- Verify
customer.subscription.updatedorinvoice.paidwebhooks are enabled - Check webhook handler processes these events
- Verify
current_period_endis being updated correctly
Issue: Users charged but no access granted
- Check webhook was received (Stripe Dashboard)
- Check webhook processed successfully (Convex logs)
- Verify database update happened
- Check for errors in
updateMembershipStatusmutation
Issue: Duplicate charges
- Usually caused by retry logic gone wrong
- Check you're not calling
stripe.checkout.sessions.createmultiple times - Implement idempotency keys for payment creation
11.6 Maintenance Checklist
Weekly:
- Review webhook delivery success rate
- Check for any unusual failed payments
- Monitor subscription churn rate
Monthly:
- Review Stripe Dashboard for anomalies
- Check Smart Retries recovery rate
- Audit webhook processing logs
- Review customer support tickets related to billing
Quarterly:
- Update Stripe SDK version (test in staging first)
- Review and optimize retry settings based on data
- Audit security (rotate API keys)
- Test disaster recovery (webhook failures, database issues)
Quick Reference Checklist
Common Mistakes to Avoid
- ❌ Using
.convex.cloudfor webhooks → ✅ Use.convex.site - ❌ Using
constructEvent()→ ✅ UseconstructEventAsync() - ❌ Looking for
subscription.current_period_end→ ✅ Usesubscription.items.data[0].current_period_end - ❌ Forgetting to set
STRIPE_WEBHOOK_SECRETin Convex - ❌ Not including
metadatain checkout session - ❌ Only listening to
checkout.session.completed→ ✅ Listen to all lifecycle events - ❌ Not configuring Customer Portal → ✅ Essential for production
- ❌ Not enabling Smart Retries → ✅ Recovers 15-25% of failed payments
- ❌ Not testing renewal flows → ✅ Use Test Clocks
- ❌ Not implementing idempotency → ✅ Prevent duplicate processing
Environment Variables Checklist
Convex Dashboard:
-
STRIPE_SECRET_KEY(sk_test_... for dev, sk_live_... for prod) -
STRIPE_PRICE_ID(price_... for your product) -
STRIPE_WEBHOOK_SECRET(whsec_... from webhook endpoint)
Next.js .env.local:
-
NEXT_PUBLIC_SITE_URL(http://localhost:3000 for dev, https://yourdomain.com for prod)
Implementation Checklist
Phase 1-3: Setup
- Install
stripepackage - Update database schema with Stripe fields
- Get Stripe API keys (test mode)
- Create product and price in Stripe Dashboard
- Set environment variables
Phase 4: Code Implementation
- Create
convex/stripe.tswith checkout and portal actions - Create
convex/stripeDb.tswith database helpers - Create
convex/http.tswith webhook handler - Add checkout button to frontend
- Add customer portal button to frontend
- Create return page
Phase 5: Webhooks
- Get Convex
.convex.siteURL - Create webhook endpoint in Stripe Dashboard
- Add all required events (checkout, subscription, invoice events)
- Test webhooks with Stripe CLI
Phase 6: Testing
- Test successful subscription flow
- Test failed payment flow
- Test customer portal (cancel, update payment)
- Test renewal with Test Clocks
- Verify all webhooks process correctly
Phase 7: Customer Portal
- Configure portal in Stripe Dashboard
- Add branding and business information
- Enable all relevant features
- Test portal as end user
Phase 8: Revenue Recovery
- Enable Smart Retries
- Configure retry settings (4-8 retries, 2-4 weeks)
- Enable automated emails
- Test payment failure handling
- Monitor recovery dashboard
Phase 9: Comprehensive Testing
- Complete all test checklist items
- Test with different test cards
- Test subscription lifecycle with Test Clocks
- Verify webhook delivery 100% success rate
Phase 10: Production
- Switch to live mode API keys
- Create production webhook endpoint
- Configure Customer Portal (live mode)
- Enable Smart Retries (live mode)
- Test with real payment (then refund)
- Monitor for 24-48 hours
Phase 11: Best Practices (Recommended)
- Implement webhook idempotency
- Add monitoring and alerts
- Set up data consistency sync
- Review security checklist
- Create maintenance schedule
Phase 12: Advanced Features (Optional)
12.1 Free Trials
Add a free trial period before charging customers:
// In convex/stripe.ts - createCheckoutSession
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: "subscription",
line_items: [{ price: process.env.STRIPE_PRICE_ID!, quantity: 1 }],
subscription_data: {
trial_period_days: 14, // 14-day free trial
},
success_url: `${siteUrl}/checkout/return?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${siteUrl}`,
metadata: { clerkUserId: args.clerkUserId, userId: user._id },
});
Trial best practices:
- 7-14 days is standard for SaaS
- Collect payment method upfront (prevents trial abuse)
- Send email 3 days before trial ends
- Show trial status in your app UI
Handling trial end:
case "customer.subscription.trial_will_end": {
// Send reminder email 3 days before trial ends
const subscription = event.data.object;
// Email user about upcoming charge
}
12.2 Coupons & Discounts
Create and apply discount codes:
Create coupon in Stripe Dashboard:
- Go to Products → Coupons
- Click + Create coupon
- Set discount (% off or fixed amount)
- Set duration (once, forever, or repeating)
- Copy coupon ID
Apply coupon to checkout:
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: "subscription",
line_items: [{ price: process.env.STRIPE_PRICE_ID!, quantity: 1 }],
discounts: [{
coupon: "SUMMER2024", // Your coupon code
}],
// ... rest of session config
});
Allow customers to enter codes:
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: "subscription",
line_items: [{ price: process.env.STRIPE_PRICE_ID!, quantity: 1 }],
allow_promotion_codes: true, // Shows coupon field in checkout
// ... rest of session config
});
12.3 One-Time Payments
For non-subscription purchases (e.g., credits, one-time features):
// Use mode: "payment" instead of "subscription"
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: "payment", // One-time payment
line_items: [{
price_data: {
currency: "usd",
product_data: {
name: "100 Credits",
description: "One-time credit purchase",
},
unit_amount: 999, // $9.99 in cents
},
quantity: 1,
}],
success_url: `${siteUrl}/checkout/return?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${siteUrl}`,
metadata: { clerkUserId: args.clerkUserId, userId: user._id },
});
Handle one-time payment webhook:
case "checkout.session.completed": {
const session = event.data.object;
if (session.mode === "payment") {
// One-time payment completed
const clerkUserId = session.metadata?.clerkUserId;
// Grant one-time purchase (e.g., add credits)
await ctx.runMutation(internal.stripeDb.addCredits, {
clerkUserId,
amount: 100,
});
}
}
One-time vs Subscription - When to use which:
- Subscriptions: Recurring revenue (monthly/yearly plans)
- One-time: Credits, lifetime access, course purchases, add-ons
12.4 Multiple Subscription Tiers
Support different pricing tiers (Basic, Pro, Enterprise):
Setup in Stripe:
- Create separate prices for each tier
- Add price IDs to environment variables:
STRIPE_PRICE_ID_BASIC=price_basic... STRIPE_PRICE_ID_PRO=price_pro... STRIPE_PRICE_ID_ENTERPRISE=price_enterprise...
Pass tier in frontend:
const result = await createCheckoutSession({
clerkUserId: user.id,
mode: "subscription",
priceId: "price_pro...", // User selected Pro tier
});
Update createCheckoutSession action:
export const createCheckoutSession = action({
args: {
clerkUserId: v.string(),
mode: v.optional(v.union(v.literal("subscription"), v.literal("payment"))),
priceId: v.optional(v.string()), // Allow custom price ID
},
handler: async (ctx, args) => {
// ... existing code ...
const priceId = args.priceId || process.env.STRIPE_PRICE_ID!;
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: args.mode || "subscription",
line_items: [{ price: priceId, quantity: 1 }],
// ... rest of config
});
},
});
Store tier in database:
// Update schema
membershipTier: v.optional(v.union(
v.literal("basic"),
v.literal("pro"),
v.literal("enterprise")
)),
// Update in webhook
await ctx.db.patch(user._id, {
membershipStatus: "premium",
membershipTier: "pro", // Store which tier
membershipExpiry: currentPeriodEnd * 1000,
stripeSubscriptionId: subscriptionId,
});
Customer Portal upgrades/downgrades:
- Configure product catalog in Customer Portal settings
- Add all your price tiers
- Users can upgrade/downgrade themselves
- Stripe handles proration automatically
Resources
- See
resources/common-mistakes.mdfor detailed error solutions - See
resources/return-page-example.tsxfor full return page code - Stripe Checkout Docs
- Stripe Webhooks Guide
- Stripe Customer Portal
- Smart Retries
- Test Clocks
- Convex HTTP Actions