Skip to main content

Command Palette

Search for a command to run...

Stripe Lifetime Access with Convex

Published
3 min read
Stripe Lifetime Access with Convex

Introduction

You built a thing. You want people to pay once and use it forever. Here is exactly how to do that with Convex and @convex-dev/stripe.


Install

npm install @convex-dev/stripe stripe
npx convex env set STRIPE_KEY sk_test_xxx
npx convex env set STRIPE_WEBHOOKS_SECRET whsec_xxx
npx convex env set HOSTING_URL http://localhost:5173
# prod
npx convex env set HOSTING_URL https://yourdomain.com --prod

Stripe Dashboard — two things only

  1. API keys → Developers → API keys → copy the secret key

  2. Webhook → Developers → Webhooks → Add endpoint

    • URL: https://your-deployment.convex.site/stripe

    • Event: checkout.session.completed

    • Copy the signing secret


Schema

// convex/schema.ts
users: defineTable({
  email: v.string(),
  userId: v.string(),
  hasPaid: v.boolean(),
}).index("by_userId", ["userId"]);

One boolean. That is all that matters.


Create a checkout session

// convex/stripe.ts
"use node";

export const pay = action({
  handler: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new Error("Not logged in");

    const domain = process.env.HOSTING_URL;
    const stripe = new Stripe(process.env.STRIPE_KEY!);

    const session = await stripe.checkout.sessions.create({
      line_items: [
        {
          price_data: {
            currency: "USD",
            unit_amount: 999, // $9.99
            product_data: {
              name: "Your App — Lifetime Access",
              description: "Pay once. Use forever.",
            },
          },
          quantity: 1,
        },
      ],
      mode: "payment",
      success_url: `${domain}?success=true`,
      cancel_url: `${domain}`,
      metadata: {
        userId: identity.subject, // attach user to session
      },
    });

    return session.url;
  },
});

On the frontend, call this action and redirect.

const pay = useAction(api.stripe.pay);

const handlePay = async () => {
  const url = await pay();
  window.location.href = url!;
};

Webhook — flip hasPaid to true

// convex/http.ts
http.route({
  path: "/stripe",
  method: "POST",
  handler: httpAction(async (ctx, request) => {
    const body = await request.text();
    const signature = request.headers.get("stripe-signature")!;

    await ctx.runAction(internal.stripe.handleWebhook, { body, signature });

    return new Response(null, { status: 200 });
  }),
});
// convex/stripe.ts (continued)
export const handleWebhook = internalAction({
  args: { body: v.string(), signature: v.string() },
  handler: async (ctx, { body, signature }) => {
    const stripe = new Stripe(process.env.STRIPE_KEY!);

    const event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOKS_SECRET!,
    );

    if (event.type === "checkout.session.completed") {
      const session = event.data.object;
      const userId = session.metadata?.userId;

      await ctx.runMutation(internal.users.markPaid, { userId });
    }
  },
});
// convex/users.ts
export const markPaid = internalMutation({
  args: { userId: v.string() },
  handler: async (ctx, { userId }) => {
    const user = await ctx.db
      .query("users")
      .withIndex("by_userId", (q) => q.eq("userId", userId))
      .unique();

    if (user) await ctx.db.patch(user._id, { hasPaid: true });
  },
});

Gate the UI

const user = useQuery(api.users.me);

if (!user?.hasPaid) {
  return <button onClick={handlePay}>Unlock forever — $9.99</button>;
}

return <YourApp />;

That's it

  • User signs in

  • Sees locked UI with a pay button

  • Pays once

  • Stripe hits your webhook

  • hasPaid flips to true

  • UI unlocks forever

No subscriptions. No trials. No bullshit.