ScaleRocket/Web

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 Portal

All 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 Portal

All 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

EventAction
checkout.session.completedCreate subscription, grant credits
customer.subscription.updatedUpdate plan, status, period
customer.subscription.deletedMark canceled, revoke access
invoice.payment_failedSet past_due, notify user
invoice.payment_succeededReset 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-webhook
stripe listen --forward-to https://YOUR_DEV_DEPLOYMENT.convex.site/stripe-webhook

Done reading? Mark this page as complete.

On this page