Payments
Stripe integration with checkout, billing portal, webhooks, and subscription management.
Overview
ScaleRocket integrates Stripe for payments. The flow uses Stripe Checkout for purchases, Stripe Billing Portal for management, and a server-side function for webhook processing.
Architecture
User -> Checkout Button -> Edge Function (stripe-checkout) -> Stripe Checkout
Stripe -> Webhook -> Edge Function (stripe-webhook) -> Update Database
User -> Manage Button -> Edge Function (stripe-portal) -> Stripe Billing PortalAll Stripe logic runs server-side in Edge Functions. The client never touches Stripe keys.
User -> Checkout Button -> Convex Action (stripe-checkout) -> Stripe Checkout
Stripe -> Webhook -> Convex HTTP Endpoint (stripe-webhook) -> Update Database
User -> Manage Button -> Convex Action (stripe-portal) -> Stripe Billing PortalAll Stripe logic runs server-side in Convex functions. The client never touches Stripe keys.
Checkout Flow
1. User clicks a pricing plan
The app calls the stripe-checkout Edge Function:
const { data } = await supabase.functions.invoke("stripe-checkout", {
body: {
priceId: "price_xxx",
mode: "subscription", // or "payment" for one-time
},
});
// Redirect to Stripe Checkout
window.location.href = data.url;The app calls the stripeCheckout Convex action:
import { useAction } from "convex/react";
import { api } from "../../convex/_generated/api";
const createCheckout = useAction(api.stripe.createCheckoutSession);
const { url } = await createCheckout({
priceId: "price_xxx",
mode: "subscription",
});
window.location.href = url;2. Server function creates a Checkout Session
// supabase/functions/stripe-checkout/index.ts
const session = await stripe.checkout.sessions.create({
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
mode: "subscription",
success_url: `${SITE_URL}/dashboard?success=true`,
cancel_url: `${SITE_URL}/pricing`,
metadata: {
user_id: user.id,
},
});
return new Response(JSON.stringify({ url: session.url }));// convex/stripe.ts
export const createCheckoutSession = action({
args: { priceId: v.string(), mode: v.string() },
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Unauthorized");
const session = await stripe.checkout.sessions.create({
customer: customerId,
line_items: [{ price: args.priceId, quantity: 1 }],
mode: "subscription",
success_url: `${SITE_URL}/dashboard?success=true`,
cancel_url: `${SITE_URL}/pricing`,
metadata: { user_id: userId },
});
return { url: session.url };
},
});3. After payment, Stripe sends a webhook
The webhook handler processes the event and updates the database.
Billing Portal
Let users manage their subscription (cancel, update card, view invoices):
const { data } = await supabase.functions.invoke("stripe-portal", {
body: {
returnUrl: `${window.location.origin}/dashboard/settings`,
},
});
window.location.href = data.url;const createPortal = useAction(api.stripe.createPortalSession);
const { url } = await createPortal({
returnUrl: `${window.location.origin}/dashboard/settings`,
});
window.location.href = url;Webhook Handling
The stripe-webhook Edge Function at supabase/functions/stripe-webhook/index.ts handles all Stripe events:
// Verify the webhook signature
const signature = req.headers.get("stripe-signature");
const event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
switch (event.type) {
case "checkout.session.completed":
// Create or update subscription record
await handleCheckoutCompleted(event.data.object);
break;
case "customer.subscription.updated":
// Update subscription status, period end
await handleSubscriptionUpdated(event.data.object);
break;
case "customer.subscription.deleted":
// Mark subscription as canceled
await handleSubscriptionDeleted(event.data.object);
break;
case "invoice.payment_failed":
// Update status to past_due, send email
await handlePaymentFailed(event.data.object);
break;
}Webhook URL: https://YOUR_PROJECT_REF.supabase.co/functions/v1/stripe-webhook
The Convex HTTP endpoint at convex/http.ts handles all Stripe events:
// convex/http.ts
import { httpRouter } from "convex/server";
const http = httpRouter();
http.route({
path: "/stripe-webhook",
method: "POST",
handler: httpAction(async (ctx, req) => {
const signature = req.headers.get("stripe-signature");
const body = await req.text();
const event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
switch (event.type) {
case "checkout.session.completed":
await ctx.runMutation(internal.stripe.handleCheckoutCompleted, { ... });
break;
case "customer.subscription.updated":
await ctx.runMutation(internal.stripe.handleSubscriptionUpdated, { ... });
break;
// ... other events
}
return new Response("ok", { status: 200 });
}),
});Webhook URL: https://YOUR_DEPLOYMENT.convex.site/stripe-webhook
Handled Events
| Event | Action |
|---|---|
checkout.session.completed | Create subscription, grant credits |
customer.subscription.updated | Update plan, status, period |
customer.subscription.deleted | Mark canceled, revoke access |
invoice.payment_failed | Set past_due, notify user |
invoice.payment_succeeded | Reset credits, confirm renewal |
Subscription Lifecycle
Free User -> Checkout -> Active Subscriber
Active -> Cancel -> Canceled (access until period end)
Active -> Payment Failed -> Past Due -> Retry -> Active / Canceled
Active -> Upgrade/Downgrade -> Updated (via Billing Portal)Check subscription status in the app:
const { data: subscription } = await supabase
.from("subscriptions")
.select("*")
.eq("user_id", user.id)
.single();
const isActive = subscription?.status === "active";
const isPro = subscription?.plan_id === "pro";Price Configuration
All plans are defined in packages/config/src/pricing.ts:
export const plans = [
{
id: "starter",
name: "Starter",
description: "For individuals getting started",
price: { monthly: 9, yearly: 90 },
stripePriceId: {
monthly: "price_starter_monthly",
yearly: "price_starter_yearly",
},
credits: 100,
features: ["Feature A", "Feature B"],
},
{
id: "pro",
name: "Pro",
description: "For growing teams",
price: { monthly: 29, yearly: 290 },
stripePriceId: {
monthly: "price_pro_monthly",
yearly: "price_pro_yearly",
},
credits: 1000,
features: ["Everything in Starter", "Feature C", "Feature D"],
},
];Replace the stripePriceId values with your actual Stripe Price IDs from the Stripe dashboard.
Testing Payments
Use Stripe test mode with test card numbers:
- Success:
4242 4242 4242 4242 - Decline:
4000 0000 0000 0002 - Requires auth:
4000 0025 0000 3155
Forward webhooks locally:
stripe listen --forward-to http://localhost:54321/functions/v1/stripe-webhookstripe listen --forward-to https://YOUR_DEV_DEPLOYMENT.convex.site/stripe-webhookDone reading? Mark this page as complete.