GuidesStripe Integration
Webhooks
Handle Stripe events with webhooks
Stripe Webhooks
Webhooks notify your app about payment events in real-time.
Always verify webhook signatures to prevent unauthorized requests.
Webhook Endpoint
import { headers } from "next/headers";
import Stripe from "stripe";
import { fetchAction } from "convex/nextjs";
import { api } from "@/convex/_generated/api";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-12-18.acacia",
});
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: Request) {
const body = await req.text();
const signature = headers().get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
webhookSecret
);
} catch (err) {
console.error("Webhook signature verification failed:", err);
return Response.json({ error: "Invalid signature" }, { status: 400 });
}
// Handle event
switch (event.type) {
case "checkout.session.completed":
await handleCheckoutCompleted(event.data.object);
break;
case "customer.subscription.updated":
await handleSubscriptionUpdated(event.data.object);
break;
case "customer.subscription.deleted":
await handleSubscriptionDeleted(event.data.object);
break;
case "invoice.payment_succeeded":
await handlePaymentSucceeded(event.data.object);
break;
case "invoice.payment_failed":
await handlePaymentFailed(event.data.object);
break;
default:
console.log(`Unhandled event: ${event.type}`);
}
return Response.json({ received: true });
}Event Handlers
Checkout Completed
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
const { userId, organizationId } = session.metadata!;
await fetchAction(api.stripe.activateSubscription, {
userId,
organizationId,
stripeCustomerId: session.customer as string,
stripeSubscriptionId: session.subscription as string,
});
}Subscription Updated
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
await fetchAction(api.stripe.updateSubscription, {
stripeSubscriptionId: subscription.id,
status: subscription.status,
currentPeriodEnd: subscription.current_period_end,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
});
}Subscription Deleted
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
await fetchAction(api.stripe.cancelSubscription, {
stripeSubscriptionId: subscription.id,
});
}Payment Succeeded
async function handlePaymentSucceeded(invoice: Stripe.Invoice) {
await fetchAction(api.stripe.recordPayment, {
stripeInvoiceId: invoice.id,
amount: invoice.amount_paid,
currency: invoice.currency,
});
}Payment Failed
async function handlePaymentFailed(invoice: Stripe.Invoice) {
await fetchAction(api.stripe.handleFailedPayment, {
stripeCustomerId: invoice.customer as string,
stripeInvoiceId: invoice.id,
});
}Convex Actions
export const activateSubscription = action({
args: {
userId: v.string(),
organizationId: v.optional(v.id("organizations")),
stripeCustomerId: v.string(),
stripeSubscriptionId: v.string(),
},
handler: async (ctx, args) => {
// Update organization with subscription info
await ctx.runMutation(internal.organizations.updateSubscription, {
organizationId: args.organizationId!,
stripeCustomerId: args.stripeCustomerId,
stripeSubscriptionId: args.stripeSubscriptionId,
status: "active",
});
// Send confirmation email
// Add to analytics
},
});
export const updateSubscription = action({
args: {
stripeSubscriptionId: v.string(),
status: v.string(),
currentPeriodEnd: v.number(),
cancelAtPeriodEnd: v.boolean(),
},
handler: async (ctx, args) => {
await ctx.runMutation(internal.organizations.updateSubscriptionStatus, args);
},
});Setup Webhook Endpoint
Local Testing (Stripe CLI)
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Login
stripe login
# Forward webhooks
stripe listen --forward-to localhost:3000/api/webhooks/stripeCopy the webhook signing secret:
STRIPE_WEBHOOK_SECRET=whsec_...Production Setup
- Go to Stripe Dashboard → Webhooks
- Click Add endpoint
- Enter URL:
https://yourdomain.com/api/webhooks/stripe - Select events:
checkout.session.completedcustomer.subscription.updatedcustomer.subscription.deletedinvoice.payment_succeededinvoice.payment_failed
- Copy signing secret to production environment
Important Events
Subscription Lifecycle
checkout.session.completed → Subscription created
customer.subscription.updated → Plan changed
customer.subscription.deleted → Subscription canceledPayment Events
invoice.created → New invoice
invoice.payment_succeeded → Payment successful
invoice.payment_failed → Payment failed
invoice.payment_action_required → 3D Secure requiredCustomer Events
customer.created → New customer
customer.updated → Customer details changed
customer.deleted → Customer deletedError Handling
export async function POST(req: Request) {
try {
// Webhook processing
} catch (err) {
console.error("Webhook error:", err);
// Return 200 to acknowledge receipt
// Log error for manual review
return Response.json({
error: "Webhook processing failed",
logged: true
}, { status: 200 });
}
}Return 200 status even on errors to prevent Stripe from retrying. Log errors for manual review.
Testing Webhooks
# Test specific event
stripe trigger checkout.session.completed
# Test with CLI
stripe listen --forward-to localhost:3000/api/webhooks/stripe --events checkout.session.completed,customer.subscription.updatedWebhook Logs
View webhook delivery attempts in Stripe Dashboard.