Convex Stripe
A demo project is available at https://convex-stripe-demo.vercel.app/.
Stripe syncing, subscriptions and checkouts for Convex apps. Implemented according to the best practices listed in Theo's Stripe Recommendations.
Installation
npm install @raideno/convex-stripe stripepnpm add @raideno/convex-stripe stripeyarn add @raideno/convex-stripe stripebun add @raideno/convex-stripe stripeConfiguration
1. Set up Stripe
- Create a Stripe account.
- Configure a webhook pointing to:
https://<your-convex-app>.convex.site/stripe/webhook - Enable the following Stripe Events.
- Enable the Stripe Billing Portal.
2. Set Environment Variables on Convex
npx convex env set STRIPE_SECRET_KEY "<secret>"
npx convex env set STRIPE_WEBHOOK_SECRET "<secret>"3. Add tables.
Check Tables Schemas to know more about the synced tables.
import { defineSchema } from "convex/server";
import { stripeTables } from "@raideno/convex-stripe/server";
export default defineSchema({
...stripeTables,
// your other tables...
});4. Initialize the library
import { internalConvexStripe } from "@raideno/convex-stripe/server";
export const { stripe, store, sync, setup } = internalConvexStripe({
stripe: {
secret_key: process.env.STRIPE_SECRET_KEY!,
webhook_secret: process.env.STRIPE_WEBHOOK_SECRET!,
},
});Note: All exposed actions (store, sync, setup) are internal. Meaning they can only be called from other convex functions, you can wrap them in public actions when needed.
Important:storemust always be exported, as it is used internally.
5. Register HTTP routes
import { httpRouter } from "convex/server";
import { stripe } from "./stripe";
const http = httpRouter();
// registers POST /stripe/webhook
// registers GET /stripe/return/*
stripe.addHttpRoutes(http);
export default http;6. Stripe customers
Ideally you want to create a stripe customer the moment a new entity (user, organization, etc) is created.
An entityId refers to something you are billing. It can be a user, organization or any other thing. With each entity must be associated a stripe customer and the stripe customer can be created using the setup action.
Below are with different auth providers examples where the user is the entity we are billing.
"convex/auth.ts"
// example with convex-auth: https://labs.convex.dev/auth
import { convexAuth } from "@convex-dev/auth/server";
import { Password } from "@convex-dev/auth/providers/Password";
import { internal } from "./_generated/api";
export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
providers: [Password],
callbacks: {
afterUserCreatedOrUpdated: async (context, args) => {
await context.scheduler.runAfter(0, internal.stripe.setup, {
entityId: args.userId,
email: args.profile.email,
});
},
},
});"convex/auth.ts"
// example with better-auth: https://convex-better-auth.netlify.app/
// coming soon..."convex/auth.ts"
// example with clerk: https://docs.convex.dev/auth/clerk
// coming soon...7. Run sync action
In your convex project's dashboard. Go the Functions section and execute the sync action.
This is done to sync already existing stripe data into your convex database. It must be done in both your development and production deployments after installing or updating the library.
This might not be necessary if you are starting with a fresh empty stripe project.
8. Start building
Now you can use the provided functions to:
- Generate a subscription or payment link
stripe.subscribe,stripe.payfor a given entity. - Generate a link to the entity's
stripe.portalto manage their subscriptions. - Consult the synced tables.
- Etc.
Usage
The library automatically syncs the following tables.
You can query these tables at any time to:
- List available products/plans and prices.
- Retrieve customers and their
customerId. - Check active subscriptions.
- Etc.
setup Action
Creates or updates a Stripe customer for a given entity (user or organization). Will call stripe.customers.create under the hood.
This should be called whenever a new entity is created in your app, or when you want to ensure the entity has a Stripe customer associated with it.
import { v } from "convex/values";
import { action, internal } from "./_generated/api";
export const setupCustomer = action({
args: { entityId: v.string(), email: v.optional(v.string()) },
handler: async (context, args) => {
// Add your own auth/authorization logic here
const response = await context.runAction(internal.stripe.setup, {
entityId: args.entityId,
email: args.email, // optional, but recommended for Stripe
metadata: {
// NOTE: entityId is a reserved key and can't be used
foo: "bar",
}
});
return response.customerId;
},
});Notes:
entityIdis your app’s internal ID (user/org).customerIdis stripe's internal ID.emailis optional, but recommended so the Stripe customer has a contact email.- If the entity already has a Stripe customer, setup will return the existing one instead of creating a duplicate.
- Typically, you’ll call this automatically in your user/org creation flow (see Configuration - 6).
sync Action
Synchronizes Stripe resources with your Convex database.
This action is typically manually called or setup to be automatically called in your ci/cd pipeline.
Parameters:
data(optional, default:true): Syncs all existing Stripe resources to Convex tables.webhook(optional, default:false): Creates/updates the webhook endpoint. Returns the webhook secret if a new endpoint is created. You must set it in your convex environment variables asSTRIPE_WEBHOOK_SECRET.portal(optional, default:false): Creates the default billing portal configuration if it doesn't exist.
subscribe Function
Creates a Stripe Subscription Checkout session for a given entity. Will call stripe.checkout.sessions.create under the hood, the same parameters can be passed.
import { v } from "convex/values";
import { stripe } from "./stripe";
import { action, internal } from "./_generated/api";
export const createCheckout = action({
args: { entityId: v.string(), priceId: v.string() },
handler: async (context, args) => {
// TODO: add your own auth/authorization logic here
const response = await stripe.subscribe(context, {
entityId: args.entityId,
priceId: args.priceId,
mode: "subscription",
success_url: "http://localhost:3000/payments/success",
cancel_url: "http://localhost:3000/payments/cancel",
/*
* Other parameters from stripe.checkout.sessions.create(...)
*/
}, {
/*
* Optional Stripe Request Options
*/
});
return response.url;
},
});portal Function
Allows an entity to manage their subscription via the Stripe Portal. Will call stripe.billingPortal.sessions.create under the hood, the same parameters can be passed.
import { v } from "convex/values";
import { stripe } from "./stripe";
import { action, internal } from "./_generated/api";
export const portal = action({
args: { entityId: v.string() },
handler: async (context, args) => {
const response = await stripe.portal(context, {
entityId: args.entityId,
returnUrl: "http://localhost:3000/return-from-portal",
/*
* Other parameters from stripe.billingPortal.sessions.create(...)
*/
}, {
/*
* Optional Stripe Request Options
*/
});
return response.url;
},
});The provided entityId must have a customerId associated to it otherwise the action will throw an error.
pay Function
Creates a Stripe One Time Payment Checkout session for a given entity. Will call stripe.checkout.sessions.create under the hood, the same parameters can be passed.
import { v } from "convex/values";
import { stripe } from "./stripe";
import { action, internal } from "./_generated/api";
export const pay = action({
args: { entityId: v.string(), orderId: v.string(), priceId: v.string() },
handler: async (context, args) => {
// Add your own auth/authorization logic here
const response = await stripe.pay(context, {
referenceId: args.orderId,
entityId: args.entityId,
mode: "payment",
line_items: [{ price: args.priceId, quantity: 1 }],
success_url: `${process.env.SITE_URL}/?return-from-pay=success`,
cancel_url: `${process.env.SITE_URL}/?return-from-pay=cancel`,
/*
* Other parameters from stripe.checkout.sessions.create(...)
*/
}, {
/*
* Optional Stripe Request Options
*/
});
return response.url;
},
});Best Practices
- Always create a Stripe customer (
setup) when a new entity is created. - Use
metadataormarketing_featureson products to store feature flags or limits. - Run
syncwhen you first configure the extension to sync already existing stripe resources. - Never expose internal actions directly to clients, wrap them in public actions with proper authorization.