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
API keys → Developers → API keys → copy the secret key
Webhook → Developers → Webhooks → Add endpoint
URL:
https://your-deployment.convex.site/stripeEvent:
checkout.session.completedCopy 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
hasPaidflips to trueUI unlocks forever
No subscriptions. No trials. No bullshit.






