Convex Stripe Billing β
Status | Features |
---|---|
β Supported | Subscriptions, Checkout, Billing portal, Sync webhooks and cron, Multi-tenant. |
π§ Planned | Oneβtime payments, Usage based billing, Credits. |
A demo project is available at https://convex-billing-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 add @raideno/convex-billing stripe
pnpm add @raideno/convex-billing stripe
yarn add @raideno/convex-billing stripe
bun add @raideno/convex-billing stripe
βοΈ Configuration β
Set up Stripe
- Create a Stripe account.
- Configure a webhook pointing to:
https://<your-convex-app>.convex.site/stripe/webhook
- Enable the π‘ Stripe Events.
- Enable the Stripe Billing Portal.
Set environment variables in your Convex backend:
npx convex env set STRIPE_SECRET_KEY "<secret>"
npx convex env set STRIPE_WEBHOOK_SECRET "<secret>"
- Add billing tables to your schema:
Check π Tables Schema to know more about synced tables.
import { defineSchema } from "convex/server";
import { billingTables } from "@raideno/convex-billing/server";
export default defineSchema({
...billingTables,
// your other tables...
});
- Initialize billing in
convex/billing.ts
:
import { internalConvexBilling } from "@raideno/convex-billing/server";
export const {
// mandatory
billing,
store,
sync,
// --- --- ---
portal,
checkout,
setup,
} = internalConvexBilling({
stripe: {
secret_key: process.env.STRIPE_SECRET_KEY!,
webhook_secret: process.env.STRIPE_WEBHOOK_SECRET!,
},
});
Note: All exposed actions are internal. Meaning they can only be called from other convex functions, you can wrap them in public actions when needed.
Important:billing
,store
, andsync
must always be exported, as they are used internally.
- Register HTTP routes in
convex/http.ts
:
import { httpRouter } from "convex/server";
import { billing } from "./billing";
const http = httpRouter();
// registers POST /stripe/webhook
// registers GET /stripe/return/*
billing.addHttpRoutes(http);
export default http;
- Set up cron jobs to keep data in sync:
import { cronJobs } from "convex/server";
import { billing } from "./billing";
const crons = cronJobs();
billing.addCronJobs(crons);
export default crons;
Note: This is used as a way to sync data at startup and ensures data stays up to date, even if the server restarts or changes happen while itβs offline. You can skip this if you prefer to run the sync action manually at startup.
- Create Stripe customers when entities (users/orgs) are created.
Example with convex-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.billing.setup, {
entityId: args.userId,
email: args.profile.email,
});
},
},
});
π’ Organization-Based Billing β
If you bill organizations instead of users, call setup
when creating an organization:
import { v } from "convex/values";
import { query } from "convex/server";
import { getAuthUserId } from "@convex-dev/auth/server";
import { internal } from "./_generated/api";
export const createOrganization = query({
args: { name: v.string() },
handler: async (context, args) => {
const userId = await getAuthUserId(context);
if (!userId) throw new Error("Not authorized.");
const orgId = await context.db.insert("organizations", {
name: args.name,
ownerId: userId,
});
await context.scheduler.runAfter(0, internal.billing.setup, {
entityId: orgId,
});
return orgId;
},
});
π¦ Usage β
The library automatically syncs:
convex_billing_products
convex_billing_prices
convex_billing_customers
convex_billing_subscriptions
convex_billing_payouts
convex_billing_refunds
convex_billing_promotion_codes
convex_billing_coupons
You can query these tables at any time to:
- List available products/plans and prices.
- Retrieve customers and their
customerId
. - Check active subscriptions.
setup
Action β
Creates or updates a Stripe customer for a given entity (user or organization).
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.billing.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:
entityId
is your appβs internal ID (user/org).customerId
is stripe's internal ID.email
is 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 - 7).
checkout
Action β
Creates a Stripe Checkout session for a given entity.
import { v } from "convex/values";
import { action, internal } from "./_generated/api";
export const createCheckout = action({
args: { entityId: v.string(), priceId: v.string() },
handler: async (context, args) => {
// Add your own auth/authorization logic here
const response = await context.runAction(internal.billing.checkout, {
entityId: args.entityId,
priceId: args.priceId,
successUrl: "http://localhost:3000/payments/success",
cancelUrl: "http://localhost:3000/payments/cancel",
// NOTE: true by default. if set to false will throw an error if provided entityId don't have a customerId associated to it.
// createStripeCustomerIfMissing: true
});
return response.url;
},
});
portal
Action β
Allows an entity to manage their subscription via the Stripe Portal.
import { v } from "convex/values";
import { action, internal } from "./_generated/api";
export const portal = action({
args: { entityId: v.string() },
handler: async (context, args) => {
const response = await context.runAction(internal.billing.portal, {
entityId: args.entityId,
returnUrl: "http://localhost:3000/return-from-portal",
});
return response.url;
},
});
The provided entityId must have a customerId associated to it otherwise the action will throw an error.
β Best Practices β
- Always create a Stripe customer (
setup
) when a new entity is created. - Use
metadata
ormarketing_features
on products to store feature flags or limits. - Run
sync
periodically (via cron) to ensure data consistency. - Never expose internal actions directly to clients, wrap them in public actions with proper authorization.
π‘ Stripe Events β
The following events are handled and synced automatically:
Subscriptions (Mandatory):
checkout.session.completed
customer.subscription.created
customer.subscription.updated
customer.subscription.deleted
customer.subscription.paused
customer.subscription.resumed
customer.subscription.pending_update_applied
customer.subscription.pending_update_expired
customer.subscription.trial_will_end
customer.created
customer.deleted
invoice.paid
invoice.payment_failed
invoice.payment_action_required
invoice.upcoming
invoice.marked_uncollectible
invoice.payment_succeeded
payment_intent.succeeded
payment_intent.payment_failed
payment_intent.canceled
Products:
product.created
product.updated
product.deleted
Prices:
price.created
price.updated
price.deleted
Coupons
coupon.created
coupon.updated
coupon.deleted
Promotion Codes
promotion_code.created
promotion_code.updated
Payouts
payout.canceled
payout.created
payout.failed
payout.paid
payout.updated
payout.reconciliation_completed
Refunds
refund.created
refund.updated
refund.failed
π Resources β
π Table Schemas β
When you spread billingTables
into your Convex schema, the following tables are created automatically:
convex_billing_products
β
Stores Stripe products.
Field | Type | Description |
---|---|---|
_id | string | Convex document ID |
productId | string | Stripe product ID |
stripe | Stripe.Product | Synced stripe product data |
last_synced_at | number | Last sync timestamp (Convex) |
Indexes:
byActive
byName
convex_billing_prices
β
Stores Stripe prices.
Field | Type | Description |
---|---|---|
_id | string | Convex document ID |
priceId | string | Stripe price ID |
stripe | Stripe.Price | Synced stripe price data |
last_synced_at | number | Last sync timestamp |
Indexes:
byProductId
byActive
byRecurringInterval
byCurrency
convex_billing_customers
β
Stores mapping between your appβs entities (users/orgs) and Stripe customers.
Field | Type | Description |
---|---|---|
_id | string | Convex document ID |
entityId | string | Your appβs entity ID (user/org) |
customerId | string | Stripe customer ID |
stripe | Stripe.Customer | Synced stripe data |
last_synced_at | number | Last sync timestamp |
Indexes:
byEntityId
byCustomerId
convex_billing_subscriptions
β
Stores Stripe subscriptions.
Field | Type | Description |
---|---|---|
_id | string | Convex document ID |
customerId | string | Stripe customer ID |
subscriptionId | string | null | Subscription Id or null if none exist. |
stripe | Stripe.Subscription | null | Full Stripe subscription object Stripe.Subscription (or null ) |
last_synced_at | number | Last sync timestamp |
Index:
bySubscriptionId
byCustomerId
convex_billing_coupons
β
Stores Stripe coupons.
Field | Type | Description |
---|---|---|
_id | string | Convex document ID |
couponId | string | Stripe coupon ID |
stripe | Stripe.Coupon | Full Stripe coupon object Stripe.Coupon |
last_synced_at | number | Last sync timestamp |
Index:
byCouponId
convex_billing_promotion_codes
β
Stores Stripe promotion codes.
Field | Type | Description |
---|---|---|
_id | string | Convex document ID |
promotionCodeId | string | Stripe promotion code ID |
stripe | Stripe.PromotionCode | Full Stripe promotion code object Stripe.PromotionCode |
last_synced_at | number | Last sync timestamp |
Index:
byPromotionCodeId
convex_billing_payouts
β
Stores Stripe payouts.
Field | Type | Description |
---|---|---|
_id | string | Convex document ID |
payoutId | string | Stripe payout ID |
stripe | Stripe.Payout | Full Stripe payout object Stripe.Payout |
last_synced_at | number | Last sync timestamp |
Index:
byPayoutId
convex_billing_refunds
β
Stores Stripe refunds.
Field | Type | Description |
---|---|---|
_id | string | Convex document ID |
refundId | string | Stripe refund ID |
stripe | Stripe.Refund | Full Stripe refund object Stripe.Refund |
last_synced_at | number | Last sync timestamp |
Index:
byRefundId
β‘ These tables are synced automatically via webhooks and cron jobs.
You can query them directly in your Convex functions to check products, prices, and subscription status.