Stripe Lifetime Access with Convex

Just a guy who loves to write code and watch anime.
Search for a command to run...

Just a guy who loves to write code and watch anime.
No comments yet. Be the first to comment.
Qualities that make outstanding builders.

Bipedal vs quadrupedal: how many legs This is about the number of legs the creature walks on. Bipedal. Two legs. Humans, ostriches, T-rex, kangaroos, most fantasy humanoids. Quadrupedal. Four legs. Do

Intro You hear "lerp" and "smoothstep" everywhere in game dev. They sound like math jargon. They're not. Both are small tools that do the same job: smoothly move from one value to another. The problem

What is Kinematics Kinematics is the math field. It's the study of how things move without worrying about forces (which would be dynamics). FK and IK are the two branches: forward kinematics and inver

Intro Textures are usually the biggest cost in a 3D scene. Memory, bandwidth, and load time all get eaten by them. Resizing them is the obvious lever. There's more. This post is about the less obvious

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.
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
API keys → Developers → API keys → copy the secret key
Webhook → Developers → Webhooks → Add endpoint
URL: https://your-deployment.convex.site/stripe
Event: checkout.session.completed
Copy the signing secret
// 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.
// 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!;
};
// 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 });
},
});
const user = useQuery(api.users.me);
if (!user?.hasPaid) {
return <button onClick={handlePay}>Unlock forever — $9.99</button>;
}
return <YourApp />;
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.