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 Environment Variables on Convex

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 provided functions to:

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.

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

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 as STRIPE_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.

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) => {
    // 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.

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",
      /*
       * 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.

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