Skip to content

Convex Stripe Billing ​

StatusFeatures
βœ… SupportedSubscriptions, Checkout, Billing portal, Sync webhooks and cron, Multi-tenant.
🚧 PlannedOne‑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 ​

sh
npm add @raideno/convex-billing stripe
sh
pnpm add @raideno/convex-billing stripe
sh
yarn add @raideno/convex-billing stripe
sh
bun add @raideno/convex-billing stripe

βš™οΈ Configuration ​

  1. 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.
  2. Set environment variables in your Convex backend:

bash
npx convex env set STRIPE_SECRET_KEY "<secret>"
npx convex env set STRIPE_WEBHOOK_SECRET "<secret>"
  1. Add billing tables to your schema:

Check πŸ“‘ Tables Schema to know more about synced tables.

convex/schema.ts
ts
import { defineSchema } from "convex/server";
import { billingTables } from "@raideno/convex-billing/server";

export default defineSchema({
  ...billingTables,
  // your other tables...
});
  1. Initialize billing in convex/billing.ts:
convex/billing.ts
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, and sync must always be exported, as they are used internally.

  1. Register HTTP routes in convex/http.ts:
convex/http.ts
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;
  1. Set up cron jobs to keep data in sync:
convex/crons.ts
ts
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.

  1. Create Stripe customers when entities (users/orgs) are created.
    Example with convex-auth:
convex/auth.ts
ts
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:

convex/organizations.ts
ts
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:

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.

ts
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.

ts
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.

ts
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 or marketing_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.

FieldTypeDescription
_idstringConvex document ID
productIdstringStripe product ID
stripeStripe.ProductSynced stripe product data
last_synced_atnumberLast sync timestamp (Convex)

Indexes:

  • byActive
  • byName

convex_billing_prices ​

Stores Stripe prices.

FieldTypeDescription
_idstringConvex document ID
priceIdstringStripe price ID
stripeStripe.PriceSynced stripe price data
last_synced_atnumberLast sync timestamp

Indexes:

  • byProductId
  • byActive
  • byRecurringInterval
  • byCurrency

convex_billing_customers ​

Stores mapping between your app’s entities (users/orgs) and Stripe customers.

FieldTypeDescription
_idstringConvex document ID
entityIdstringYour app’s entity ID (user/org)
customerIdstringStripe customer ID
stripeStripe.CustomerSynced stripe data
last_synced_atnumberLast sync timestamp

Indexes:

  • byEntityId
  • byCustomerId

convex_billing_subscriptions ​

Stores Stripe subscriptions.

FieldTypeDescription
_idstringConvex document ID
customerIdstringStripe customer ID
subscriptionIdstring | nullSubscription Id or null if none exist.
stripeStripe.Subscription | nullFull Stripe subscription object Stripe.Subscription(or null)
last_synced_atnumberLast sync timestamp

Index:

  • bySubscriptionId
  • byCustomerId

convex_billing_coupons ​

Stores Stripe coupons.

FieldTypeDescription
_idstringConvex document ID
couponIdstringStripe coupon ID
stripeStripe.CouponFull Stripe coupon object Stripe.Coupon
last_synced_atnumberLast sync timestamp

Index:

  • byCouponId

convex_billing_promotion_codes ​

Stores Stripe promotion codes.

FieldTypeDescription
_idstringConvex document ID
promotionCodeIdstringStripe promotion code ID
stripeStripe.PromotionCodeFull Stripe promotion code object Stripe.PromotionCode
last_synced_atnumberLast sync timestamp

Index:

  • byPromotionCodeId

convex_billing_payouts ​

Stores Stripe payouts.

FieldTypeDescription
_idstringConvex document ID
payoutIdstringStripe payout ID
stripeStripe.PayoutFull Stripe payout object Stripe.Payout
last_synced_atnumberLast sync timestamp

Index:

  • byPayoutId

convex_billing_refunds ​

Stores Stripe refunds.

FieldTypeDescription
_idstringConvex document ID
refundIdstringStripe refund ID
stripeStripe.RefundFull Stripe refund object Stripe.Refund
last_synced_atnumberLast 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.