Convex's avatar
Convex
8 months ago

Integrate Stripe with Convex in 10 mins

You've built a Convex app. It's fast, it's smooth, it's fully reactive, and now it's time to get paid. You could wire up Stripe by hand. That means checkout sessions, webhook signature verification, and keeping subscription state in sync with whatever Stripe just told you happened. Or you install Convex's official Stripe component and get one-time payments, subscriptions, and a customer portal working in about the time it takes to read this.

I'm Ross. Here's exactly how I set it up, end to end.

Diagram of the Stripe payment loopDiagram of the Stripe payment loop

Scaffolding a fresh app

I started from nothing. npm create convex@latest, Next.js for the frontend, WorkOS AuthKit for auth, and the CLI scaffolds the project for you. Then npm install and npm run dev, which stands up a fresh Convex backend and a WorkOS AuthKit instance in one go. Open localhost:3000 and you've got a working Convex plus Next.js plus AuthKit starter. That's the base everything else gets layered onto.

Installing the Stripe component

The component itself is one package:

1npm install @convex-dev/stripe
2

Then register it in convex/convex.config.ts, the same way you'd register any other Convex component:

1import { defineApp } from "convex/server";
2import stripe from "@convex-dev/stripe/convex.config.js";
3
4const app = defineApp();
5app.use(stripe);
6
7export default app;
8

That's the whole install. Everything after this is connecting it to a real Stripe account.

Wiring up Stripe: keys and webhooks

The component needs two environment variables in the Convex dashboard, under Settings, Environment Variables: STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET. I'm using a Stripe test sandbox for all of this, so nothing here touches real money.

The secret key is the easy half. Search "API keys" in Stripe's dashboard and it's right there, a straight copy-paste into Convex's environment variables.

The webhook secret takes more work, because it doesn't exist until you create a webhook endpoint. Over in Stripe's dashboard:

  1. Go to Webhooks and click add destination.
  2. Select the events the component tracks. They cover customers, subscriptions, invoices, payment intents, and completed checkout sessions, and the component's docs list the exact set.
  3. For the endpoint URL, use the HTTP Actions URL from the Convex dashboard, not the regular deployment URL, with /stripe/webhook appended to it.
  4. Click create destination.

Stripe generates the signing secret at that point. Copy it into STRIPE_WEBHOOK_SECRET back in Convex and the wiring is done.

Registering the webhook route

With both environment variables set, the webhook needs somewhere to land. That's a convex/http.ts file:

1import { httpRouter } from "convex/server";
2import { components } from "./_generated/api";
3import { registerRoutes } from "@convex-dev/stripe";
4
5const http = httpRouter();
6
7// Register Stripe webhook handler at /stripe/webhook
8registerRoutes(http, components.stripe, {
9  webhookPath: "/stripe/webhook",
10});
11
12export default http;
13

registerRoutes does the real work here. It verifies the webhook signature against STRIPE_WEBHOOK_SECRET and turns incoming Stripe events into writes against the component's own tables, all as an HTTP action. None of that is code I had to write.

Checkout actions

Next up is convex/stripe.ts, where the checkout logic lives:

1import { action } from "./_generated/server";
2import { components } from "./_generated/api";
3import { StripeSubscriptions } from "@convex-dev/stripe";
4import { v } from "convex/values";
5
6const stripeClient = new StripeSubscriptions(components.stripe, {});
7
8// Create a checkout session for a subscription
9export const createSubscriptionCheckout = action({
10  args: { priceId: v.string() },
11  handler: async (ctx, args) => {
12    const identity = await ctx.auth.getUserIdentity();
13    if (!identity) throw new Error("Not authenticated");
14
15    const customer = await stripeClient.getOrCreateCustomer(ctx, {
16      userId: identity.subject,
17      email: identity.email,
18      name: identity.name,
19    });
20
21    return await stripeClient.createCheckoutSession(ctx, {
22      priceId: args.priceId,
23      customerId: customer.customerId,
24      mode: "subscription",
25      successUrl: "http://localhost:3000/?success=true",
26      cancelUrl: "http://localhost:3000/?canceled=true",
27      subscriptionMetadata: { userId: identity.subject },
28    });
29  },
30});
31
32// Create a checkout session for a one-time payment
33export const createPaymentCheckout = action({
34  args: { priceId: v.string() },
35  handler: async (ctx, args) => {
36    const identity = await ctx.auth.getUserIdentity();
37    if (!identity) throw new Error("Not authenticated");
38
39    const customer = await stripeClient.getOrCreateCustomer(ctx, {
40      userId: identity.subject,
41      email: identity.email,
42      name: identity.name,
43    });
44
45    return await stripeClient.createCheckoutSession(ctx, {
46      priceId: args.priceId,
47      customerId: customer.customerId,
48      mode: "payment",
49      successUrl: "http://localhost:3000/?success=true",
50      cancelUrl: "http://localhost:3000/?canceled=true",
51      paymentIntentMetadata: { userId: identity.subject },
52    });
53  },
54});
55

The StripeSubscriptions client carries a lot more than those two actions. It also exposes createCustomerPortalSession, createCustomer, getOrCreateCustomer, cancelSubscription, reactivateSubscription, and updateSubscriptionQuantity, plus public queries for reading customers, subscriptions, payments, and invoices straight out of Convex. The repo's Benji Store example app puts several more of them to work. Clone it if you want a fuller reference than the quick start.

Gating a page behind a subscription

With the component wired up, I added a dashboard page that requires authentication and an active subscription. Miss either one and you get a pricing card instead of the dashboard content. The gate comes down to one query against the component's own data:

1// TODO: Replace with your actual Stripe Price ID from your Stripe Dashboard
2const SUBSCRIPTION_PRICE_ID = "price_1SXtxIGadJM86FzJ4R7ojxYf";
3
4export default function Dashboard() {
5  const { user, signOut } = useAuth();
6  const subscriptions = useQuery(api.stripe.getUserSubscriptions);
7  const payments = useQuery(api.stripe.getUserPayments);
8  const getPortalUrl = useAction(api.stripe.getCustomerPortalUrl);
9  const createCheckout = useAction(api.stripe.createSubscriptionCheckout);
10  const [portalLoading, setPortalLoading] = useState(false);
11  const [checkoutLoading, setCheckoutLoading] = useState(false);
12
13  // Check if user has an active subscription
14  const activeSubscription = subscriptions?.find(
15    (sub) => sub.status === 'active' || sub.status === 'trialing'
16  );
17

No activeSubscription means no dashboard, just the pricing card. Because it's a Convex query, the page re-renders the moment the subscription lands, with no refresh and no polling.

On the backend, getUserSubscriptions is a thin wrapper over the component's own public query:

1export const getUserSubscriptions = query({
2  returns: v.array(
3    // ...currentPeriodEnd, cancelAtPeriodEnd, metadata, userId, orgId
4  ),
5  handler: async (ctx, args) => {
6    const identity = await ctx.auth.getUserIdentity();
7    if (!identity) return [];
8
9    return await ctx.runQuery(
10      components.stripe.public.listSubscriptionsByUserId,
11      { userId: identity.subject },
12    );
13  },
14});
15

Checkout needs one more thing before it works, a real price ID. You get that from a product in Stripe's product catalog. I hardcoded mine for the demo, but it's the value that gets passed as priceId into createSubscriptionCheckout, and it's how Stripe knows which product it's selling.

Testing the flow

Clicking subscribe calls createSubscriptionCheckout and redirects to a real Stripe Checkout page for the configured product. Pay with Stripe's test card, 4242 4242 4242 4242, any future expiration date, any CVC, and the checkout completes without touching real money.

The dashboard unlocks right away. Open the Convex dashboard's Data tab, look under the stripe component, and you can see exactly what the webhook wrote: the checkout session, the customer record, invoices, payments, and the subscription itself. All of it synced from Stripe's events into Convex tables I never had to define.

Managing billing

Canceling is just as short. A "manage billing" action calls createCustomerPortalSession with the customer ID pulled off the user's subscription record. Stripe's own hosted portal handles the rest, cancellation included.

Where to go from here

That's the core loop: one-time payments, subscriptions, webhooks, and a customer portal, all from one npm install. The component goes further than that. It supports seat-based team pricing and lets you link payments or subscriptions to an organization instead of an individual user, both of which show up in the example app. Everything here is normal, readable TypeScript, so it's easy to hand to whatever AI coding agent you're already using and ask it to extend.

All gas, no breakages

Convex is the reactive backend platform that keeps up with you and your agents. Database, functions, workflow, sync, search, file storage, and more. All TypeScript, zero glue.

Get started