Skip to content

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

sh
npm install @raideno/convex-stripe stripe
sh
pnpm add @raideno/convex-stripe stripe
sh
yarn add @raideno/convex-stripe stripe
sh
bun add @raideno/convex-stripe 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 following Stripe Events.
  • Enable the Stripe Billing Portal.

2. Set ENV

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

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

export default defineSchema({
  ...stripeTables,
  // your other tables...
});

4. Initialize the library

convex/stripe.ts
ts
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: store must always be exported, as it is used internally.

5. Register HTTP routes

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

ts
"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,
      });
    },
  },
});
ts
"convex/auth.ts"

// example with better-auth: https://convex-better-auth.netlify.app/

// coming soon...
ts
"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 different provided functions to:

  • Generate a subscription or payment link stripe.subscribe, stripe.pay for a given entity.
  • Generate a link to the entity's stripe.portal to manage their subscriptions.
  • Consult the different 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).

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

  • 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 - 6).

sync Action

Sync all existing data on stripe to convex database.

subscribe Function

Creates a Stripe Subscription Checkout session for a given entity.

ts
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) => {
    // Add your own auth/authorization logic here

    const response = await stripe.subscribe(context, {
      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 Function

Allows an entity to manage their subscription via the Stripe Portal.

ts
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",
    });

    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.

ts
import { v } from "convex/values";

import { stripe } from "./stripe";
import { action, internal } from "./_generated/api";

export const subscribe = action({
  args: { entityId: v.string(), priceId: v.string() },
  handler: async (context, args) => {
    // Add your own auth/authorization logic here

    const response = await stripe.pay(context, {
      // TODO: complete
    });

    return response.url;
  },
});

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

Resources