| 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({
membershipStatus: v.optional(v.union(
v.literal("free"),
v.literal("premium"),
v.literal("past_due")
)),
membershipExpiry: v.optional(v.number()),
stripeCustomerId: v.optional(v.string()),
stripeSubscriptionId: v.optional(v.string()),
})
.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_...)
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";
export const createCheckoutSession = action({
args: {
clerkUserId: v.string(),
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,
});
const user: any = await ctx.runQuery(internal.stripeDb.getUserByClerkId, {
clerkId: args.clerkUserId,
});
if (!user) {
throw new Error("User not found");
}
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;
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";
const session: Stripe.Checkout.Session = await stripe.checkout.sessions.create({
customer: customerId,
mode: mode,
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,
};
},
});
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,
};
},
});
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,
});
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";
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";
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;
},
});
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,
});
},
});
export const updateMembershipStatus = internalMutation({
args: {
clerkUserId: v.string(),
stripeSubscriptionId: v.string(),
currentPeriodEnd: v.number(),
},
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,
stripeSubscriptionId: args.stripeSubscriptionId,
});
return user._id;
},
});
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;
},
});
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;
}
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();
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 {
const event = await stripe.webhooks.constructEventAsync(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as any;
const clerkUserId = session.metadata?.clerkUserId;
if (session.mode === "subscription") {
const subscriptionId = session.subscription as string;
if (!clerkUserId || !subscriptionId) {
console.error("Missing clerkUserId or subscriptionId");
break;
}
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;
}
const { internal } = await import("./_generated/api.js");
await ctx.runMutation(internal.stripeDb.updateMembershipStatus, {
clerkUserId,
stripeSubscriptionId: subscriptionId,
currentPeriodEnd,
});
console.log(`✅ Membership activated for user: ${clerkUserId}`);
}
if (session.mode === "payment") {
console.log(`✅ One-time payment completed for user: ${clerkUserId}`);
}
break;
}
case "customer.subscription.updated": {
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;
}
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": {
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": {
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": {
const invoice = event.data.object as any;
const subscriptionId = invoice.subscription;
if (!subscriptionId) {
break;
}
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",
});
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
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)
brew install stripe/stripe-cli/stripe
stripe login
stripe listen --forward-to https://your-deployment.convex.site/stripe/webhook
Phase 6: Dev Mode Testing
6.1 Test Checklist
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
- 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:
export const handlePaymentFailure = internalMutation({
args: {
stripeSubscriptionId: v.string(),
attemptCount: v.number(),
},
handler: async (ctx, args) => {
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:
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:
brew install stripe/stripe-cli/stripe
scoop install stripe
Forward webhooks to Convex:
stripe login
stripe listen --forward-to https://your-deployment.convex.site/stripe/webhook
Test specific events:
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failed
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:
Customer Portal Flow:
Payment Failure Flow:
Renewal Flow:
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_SECRET in 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):
Monitor for 24-48 hours:
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:
export const processedWebhookEvents = defineTable({
eventId: v.string(),
processedAt: v.number(),
}).index("by_event_id", ["eventId"]);
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:
const { internal } = await import("./_generated/api.js");
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 });
}
await ctx.runMutation(internal.stripeDb.markEventProcessed, {
eventId: event.id,
});
Event Ordering - Don't Assume Order
Stripe doesn't guarantee event order. Handle events independently:
Best practice: Always fetch the latest subscription state from Stripe if you need current data:
case "invoice.payment_failed": {
const invoice = event.data.object;
const subscription = await stripe.subscriptions.retrieve(invoice.subscription);
}
11.2 Monitoring & Alerting
Set Up Monitoring
Webhook health checks:
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:
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;
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const subscription = await stripe.subscriptions.retrieve(user.stripeSubscriptionId);
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:
Monthly:
Quarterly:
Quick Reference Checklist
Common Mistakes to Avoid
- ❌ Using
.convex.cloud for webhooks → ✅ Use .convex.site
- ❌ Using
constructEvent() → ✅ Use constructEventAsync()
- ❌ Looking for
subscription.current_period_end → ✅ Use subscription.items.data[0].current_period_end
- ❌ Forgetting to set
STRIPE_WEBHOOK_SECRET in Convex
- ❌ Not including
metadata in 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:
Next.js .env.local:
Implementation Checklist
Phase 1-3: Setup
Phase 4: Code Implementation
Phase 5: Webhooks
Phase 6: Testing
Phase 7: Customer Portal
Phase 8: Revenue Recovery
Phase 9: Comprehensive Testing
Phase 10: Production
Phase 11: Best Practices (Recommended)
Phase 12: Advanced Features (Optional)
12.1 Free Trials
Add a free trial period before charging customers:
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,
},
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": {
const subscription = event.data.object;
}
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",
}],
});
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,
});
12.3 One-Time Payments
For non-subscription purchases (e.g., credits, one-time features):
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: "payment",
line_items: [{
price_data: {
currency: "usd",
product_data: {
name: "100 Credits",
description: "One-time credit purchase",
},
unit_amount: 999,
},
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") {
const clerkUserId = session.metadata?.clerkUserId;
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...",
});
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()),
},
handler: async (ctx, args) => {
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 }],
});
},
});
Store tier in database:
membershipTier: v.optional(v.union(
v.literal("basic"),
v.literal("pro"),
v.literal("enterprise")
)),
await ctx.db.patch(user._id, {
membershipStatus: "premium",
membershipTier: "pro",
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