--- 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 ( ); } ``` #### 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 ( ); } ``` ### 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 - renewals & updates) - `customer.subscription.deleted` (required - cancellations) - `invoice.payment_failed` (required - failed payments) - `invoice.paid` (recommended - successful renewals) 5. Click **Add endpoint** 6. Copy the **Signing secret** (starts with `whsec_...`) 7. Add to Convex environment: `STRIPE_WEBHOOK_SECRET=whsec_...` ### 5.3 Test with Stripe CLI (Optional but Recommended) ```bash # 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 - [ ] 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.site` not `.convex.cloud` - **Fix:** Verify webhook secret is set correctly in Convex env vars **Issue:** `SubtleCryptoProvider cannot be used in a synchronous context` - **Fix:** Use `constructEventAsync` not `constructEvent` **Issue:** Membership status not updating - **Fix:** Check `current_period_end` is accessed from `subscription.items.data[0]` - **Fix:** Verify `clerkUserId` is 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 1. Go to [Customer Portal Settings](https://dashboard.stripe.com/settings/billing/portal) in Stripe Dashboard 2. **Business information** - Add your logo, icon, and brand colors - Add support email and phone number - Add Terms of Service and Privacy Policy URLs 3. **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 4. 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: ```tsx {user.stripeCustomerId && } ``` ## 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:** 1. Go to [Revenue Recovery → Retries](https://dashboard.stripe.com/revenue_recovery/retries) 2. Toggle on **Smart Retries** 3. Configure retry settings: - **Number of retries:** 4-8 retries recommended - **Duration:** 2-4 weeks recommended 4. 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) 5. 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: ```typescript // 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) 1. Go to [Billing → Revenue recovery → Emails](https://dashboard.stripe.com/revenue_recovery/customer_emails) 2. 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 3. Customize email templates with your branding 4. 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](https://dashboard.stripe.com/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:** ```bash # 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:** ```bash # 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:** ```bash # 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 - [ ] `membershipStatus` updates to "premium" in Convex - [ ] `membershipExpiry` is set correctly (1 month from now for monthly) - [ ] Webhook `checkout.session.completed` received 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.deleted` webhook - [ ] `membershipStatus` updates to "free" after cancellation **Payment Failure Flow:** - [ ] Use test card `4000 0000 0000 9995` to trigger failure - [ ] `invoice.payment_failed` webhook received - [ ] `handlePaymentFailure` mutation 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.updated` or `invoice.paid` webhook fires - [ ] `membershipExpiry` extends by another month ### 9.4 Test Clocks (Advanced - Simulate Time) Test Clocks let you simulate subscription renewals without waiting weeks/months. **Create Test Clock:** 1. Go to [Workbench → Test Clocks](https://dashboard.stripe.com/test/test-clocks) 2. Click **Create test clock** 3. Set start time to "now" 4. Create customer and subscription using this test clock 5. Advance time by 1 month to test renewal 6. 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:** 1. Go to **Developers → Webhooks** 2. Click on your webhook endpoint 3. View **Event deliveries** tab 4. 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 1. **Get Live API Keys** from Stripe Dashboard (toggle to Live mode) 2. **Create Production Product & Price** in Live mode 3. **Update Convex Production Environment Variables:** ``` STRIPE_SECRET_KEY=sk_live_... STRIPE_PRICE_ID=price_live_... STRIPE_WEBHOOK_SECRET=whsec_live_... ``` 4. **Update Next.js Environment:** ``` NEXT_PUBLIC_SITE_URL=https://yourdomain.com ``` ### 10.2 Create Production Webhook 1. In Stripe Dashboard (Live mode) → Webhooks 2. Add endpoint: `https://your-prod-deployment.convex.site/stripe/webhook` 3. Select all required events (same as test mode) 4. Copy new signing secret 5. Update `STRIPE_WEBHOOK_SECRET` in Convex production env ### 10.3 Configure Customer Portal (Live Mode) 1. Go to [Customer Portal Settings](https://dashboard.stripe.com/settings/billing/portal) (Live mode) 2. Configure same settings as test mode 3. Add your production URLs and branding 4. Enable desired features 5. Click **Save** ### 10.4 Enable Smart Retries (Live Mode) 1. Go to [Revenue Recovery → Retries](https://dashboard.stripe.com/revenue_recovery/retries) (Live mode) 2. Enable Smart Retries with same settings as test mode 3. Enable automated emails for payment failures 4. 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: ```typescript // 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:** ```typescript // 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: ```typescript // ❌ 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: ```typescript 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:** ```typescript // 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:** 1. Webhook failure rate > 1% 2. No subscriptions created in 24 hours (if you usually get signups) 3. Failed payment rate > 15% 4. Subscription cancellation spike (>2x normal rate) ### 11.3 Data Consistency **Always sync from Stripe as source of truth:** ```typescript // 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_KEY` to 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_SECRET` secure **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_SECRET` is 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.updated` or `invoice.paid` webhooks are enabled - Check webhook handler processes these events - Verify `current_period_end` is 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 `updateMembershipStatus` mutation **Issue: Duplicate charges** - Usually caused by retry logic gone wrong - Check you're not calling `stripe.checkout.sessions.create` multiple 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 1. ❌ Using `.convex.cloud` for webhooks → ✅ Use `.convex.site` 2. ❌ Using `constructEvent()` → ✅ Use `constructEventAsync()` 3. ❌ Looking for `subscription.current_period_end` → ✅ Use `subscription.items.data[0].current_period_end` 4. ❌ Forgetting to set `STRIPE_WEBHOOK_SECRET` in Convex 5. ❌ Not including `metadata` in checkout session 6. ❌ Only listening to `checkout.session.completed` → ✅ Listen to all lifecycle events 7. ❌ Not configuring Customer Portal → ✅ Essential for production 8. ❌ Not enabling Smart Retries → ✅ Recovers 15-25% of failed payments 9. ❌ Not testing renewal flows → ✅ Use Test Clocks 10. ❌ 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 `stripe` package - [ ] 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.ts` with checkout and portal actions - [ ] Create `convex/stripeDb.ts` with database helpers - [ ] Create `convex/http.ts` with webhook handler - [ ] Add checkout button to frontend - [ ] Add customer portal button to frontend - [ ] Create return page **Phase 5: Webhooks** - [ ] Get Convex `.convex.site` URL - [ ] 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: ```typescript // 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:** ```typescript 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:** 1. Go to **Products → Coupons** 2. Click **+ Create coupon** 3. Set discount (% off or fixed amount) 4. Set duration (once, forever, or repeating) 5. Copy coupon ID **Apply coupon to checkout:** ```typescript 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:** ```typescript 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): ```typescript // 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:** ```typescript 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:** 1. Create separate prices for each tier 2. 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:** ```typescript const result = await createCheckoutSession({ clerkUserId: user.id, mode: "subscription", priceId: "price_pro...", // User selected Pro tier }); ``` **Update createCheckoutSession action:** ```typescript 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:** ```typescript // 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.md` for detailed error solutions - See `resources/return-page-example.tsx` for full return page code - [Stripe Checkout Docs](https://docs.stripe.com/checkout) - [Stripe Webhooks Guide](https://docs.stripe.com/webhooks) - [Stripe Customer Portal](https://docs.stripe.com/customer-management) - [Smart Retries](https://docs.stripe.com/billing/revenue-recovery/smart-retries) - [Test Clocks](https://docs.stripe.com/billing/testing/test-clocks) - [Convex HTTP Actions](https://docs.convex.dev/functions/http-actions)