@convex-dev/stripe
v0.1.3
Published
A stripe component for Convex.
Readme
@convex-dev/stripe
A Convex component for integrating Stripe payments, subscriptions, and billing into your Convex application.
Features
- 🛒 Checkout Sessions - Create one-time payment and subscription checkouts
- 📦 Subscription Management - Create, update, cancel subscriptions
- 👥 Customer Management - Automatic customer creation and linking
- 💳 Customer Portal - Let users manage their billing
- 🪑 Seat-Based Pricing - Update subscription quantities for team billing
- 🔗 User/Org Linking - Link payments and subscriptions to users or organizations
- 🔔 Webhook Handling - Automatic sync of Stripe data to your Convex database
- 📊 Real-time Data - Query payments, subscriptions, invoices in real-time
Quick Start
1. Install the Component
npm install @convex-dev/stripe2. Add to Your Convex App
Create or update convex/convex.config.ts:
import { defineApp } from "convex/server";
import stripe from "@convex-dev/stripe/convex.config.js";
const app = defineApp();
app.use(stripe);
export default app;3. Set Up Environment Variables
Add these to your Convex Dashboard → Settings → Environment Variables:
| Variable | Description |
| ----------------------- | ------------------------------------------------------- |
| STRIPE_SECRET_KEY | Your Stripe secret key (sk_test_... or sk_live_...) |
| STRIPE_WEBHOOK_SECRET | Webhook signing secret (whsec_...) - see Step 4 |
4. Configure Stripe Webhooks
- Go to Stripe Dashboard → Developers → Webhooks
- Click "Add endpoint"
- Enter your webhook URL:
(Find your deployment name in the Convex dashboard - it's the part beforehttps://<your-convex-deployment>.convex.site/stripe/webhook.convex.cloudin your URL) - Select these events:
checkout.session.completedcustomer.createdcustomer.updatedcustomer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.createdinvoice.finalizedinvoice.paidinvoice.payment_failedpayment_intent.succeededpayment_intent.payment_failed
- Click "Add endpoint"
- Copy the Signing secret and add it as
STRIPE_WEBHOOK_SECRETin Convex
5. Register Webhook Routes
Create convex/http.ts:
import { httpRouter } from "convex/server";
import { components } from "./_generated/api";
import { registerRoutes } from "@convex-dev/stripe";
const http = httpRouter();
// Register Stripe webhook handler at /stripe/webhook
registerRoutes(http, components.stripe, {
webhookPath: "/stripe/webhook",
});
export default http;6. Use the Component
Create convex/stripe.ts:
import { action } from "./_generated/server";
import { components } from "./_generated/api";
import { StripeSubscriptions } from "@convex-dev/stripe";
import { v } from "convex/values";
const stripeClient = new StripeSubscriptions(components.stripe, {});
// Create a checkout session for a subscription
export const createSubscriptionCheckout = action({
args: { priceId: v.string() },
returns: v.object({
sessionId: v.string(),
url: v.union(v.string(), v.null()),
}),
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
// Get or create a Stripe customer
const customer = await stripeClient.getOrCreateCustomer(ctx, {
userId: identity.subject,
email: identity.email,
name: identity.name,
});
// Create checkout session
return await stripeClient.createCheckoutSession(ctx, {
priceId: args.priceId,
customerId: customer.customerId,
mode: "subscription",
successUrl: "http://localhost:5173/?success=true",
cancelUrl: "http://localhost:5173/?canceled=true",
subscriptionMetadata: { userId: identity.subject },
});
},
});
// Create a checkout session for a one-time payment
export const createPaymentCheckout = action({
args: { priceId: v.string() },
returns: v.object({
sessionId: v.string(),
url: v.union(v.string(), v.null()),
}),
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
const customer = await stripeClient.getOrCreateCustomer(ctx, {
userId: identity.subject,
email: identity.email,
name: identity.name,
});
return await stripeClient.createCheckoutSession(ctx, {
priceId: args.priceId,
customerId: customer.customerId,
mode: "payment",
successUrl: "http://localhost:5173/?success=true",
cancelUrl: "http://localhost:5173/?canceled=true",
paymentIntentMetadata: { userId: identity.subject },
});
},
});API Reference
StripeSubscriptions Client
import { StripeSubscriptions } from "@convex-dev/stripe";
const stripeClient = new StripeSubscriptions(components.stripe, {
STRIPE_SECRET_KEY: "sk_...", // Optional, defaults to process.env.STRIPE_SECRET_KEY
});Methods
| Method | Description |
|--------|-------------|
| createCheckoutSession() | Create a Stripe Checkout session |
| createCustomerPortalSession() | Generate a Customer Portal URL |
| createCustomer() | Create a new Stripe customer |
| getOrCreateCustomer() | Get existing or create new customer |
| cancelSubscription() | Cancel a subscription |
| reactivateSubscription() | Reactivate a subscription set to cancel |
| updateSubscriptionQuantity() | Update seat count |
createCheckoutSession
await stripeClient.createCheckoutSession(ctx, {
priceId: "price_...",
customerId: "cus_...", // Optional
mode: "subscription", // "subscription" | "payment" | "setup"
successUrl: "https://...",
cancelUrl: "https://...",
quantity: 1, // Optional, default 1
metadata: {}, // Optional, session metadata
subscriptionMetadata: {}, // Optional, attached to subscription
paymentIntentMetadata: {}, // Optional, attached to payment intent
});Component Queries
Access data directly via the component's public queries:
import { query } from "./_generated/server";
import { components } from "./_generated/api";
// List subscriptions for a user
export const getUserSubscriptions = query({
args: {},
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return [];
return await ctx.runQuery(
components.stripe.public.listSubscriptionsByUserId,
{ userId: identity.subject },
);
},
});
// List payments for a user
export const getUserPayments = query({
args: {},
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return [];
return await ctx.runQuery(components.stripe.public.listPaymentsByUserId, {
userId: identity.subject,
});
},
});Available Public Queries
| Query | Arguments | Description |
| --------------------------- | ----------------------- | --------------------------------- |
| getCustomer | stripeCustomerId | Get a customer by Stripe ID |
| listSubscriptions | stripeCustomerId | List subscriptions for a customer |
| listSubscriptionsByUserId | userId | List subscriptions for a user |
| getSubscription | stripeSubscriptionId | Get a subscription by ID |
| getSubscriptionByOrgId | orgId | Get subscription for an org |
| getPayment | stripePaymentIntentId | Get a payment by ID |
| listPayments | stripeCustomerId | List payments for a customer |
| listPaymentsByUserId | userId | List payments for a user |
| listPaymentsByOrgId | orgId | List payments for an org |
| listInvoices | stripeCustomerId | List invoices for a customer |
| listInvoicesByUserId | userId | List invoices for a user |
| listInvoicesByOrgId | orgId | List invoices for an org |
Webhook Events
The component automatically handles these Stripe webhook events:
| Event | Action |
| ------------------------------- | ----------------------------------- |
| customer.created | Creates customer record |
| customer.updated | Updates customer record |
| customer.subscription.created | Creates subscription record |
| customer.subscription.updated | Updates subscription record |
| customer.subscription.deleted | Marks subscription as canceled |
| payment_intent.succeeded | Creates payment record |
| payment_intent.payment_failed | Updates payment status |
| invoice.created | Creates invoice record |
| invoice.paid | Updates invoice to paid |
| invoice.payment_failed | Marks invoice as failed |
| checkout.session.completed | Handles completed checkout sessions |
Custom Webhook Handlers
Add custom logic to webhook events:
import { httpRouter } from "convex/server";
import { components } from "./_generated/api";
import { registerRoutes } from "@convex-dev/stripe";
import type Stripe from "stripe";
const http = httpRouter();
registerRoutes(http, components.stripe, {
events: {
"customer.subscription.updated": async (ctx, event: Stripe.CustomerSubscriptionUpdatedEvent) => {
const subscription = event.data.object;
console.log("Subscription updated:", subscription.id, subscription.status);
// Add custom logic here
},
},
onEvent: async (ctx, event: Stripe.Event) => {
// Called for ALL events - useful for logging/analytics
console.log("Stripe event:", event.type);
},
});
export default http;Database Schema
The component creates these tables in its namespace:
customers
| Field | Type | Description |
| ------------------ | ------- | ------------------ |
| stripeCustomerId | string | Stripe customer ID |
| email | string? | Customer email |
| name | string? | Customer name |
| metadata | object? | Custom metadata |
subscriptions
| Field | Type | Description |
| ---------------------- | ------- | ------------------------- |
| stripeSubscriptionId | string | Stripe subscription ID |
| stripeCustomerId | string | Customer ID |
| status | string | Subscription status |
| priceId | string | Price ID |
| quantity | number? | Seat count |
| currentPeriodEnd | number | Period end timestamp |
| cancelAtPeriodEnd | boolean | Will cancel at period end |
| userId | string? | Linked user ID |
| orgId | string? | Linked org ID |
| metadata | object? | Custom metadata |
checkout_sessions
| Field | Type | Description |
| ------------------------- | ------- | ----------------------------------------- |
| stripeCheckoutSessionId | string | Checkout session ID |
| stripeCustomerId | string? | Customer ID |
| status | string | Session status |
| mode | string | Session mode (payment/subscription/setup) |
| metadata | object? | Custom metadata |
payments
| Field | Type | Description |
| ----------------------- | ------- | ----------------- |
| stripePaymentIntentId | string | Payment intent ID |
| stripeCustomerId | string? | Customer ID |
| amount | number | Amount in cents |
| currency | string | Currency code |
| status | string | Payment status |
| created | number | Created timestamp |
| userId | string? | Linked user ID |
| orgId | string? | Linked org ID |
| metadata | object? | Custom metadata |
invoices
| Field | Type | Description |
| ---------------------- | ------- | ----------------- |
| stripeInvoiceId | string | Invoice ID |
| stripeCustomerId | string | Customer ID |
| stripeSubscriptionId | string? | Subscription ID |
| status | string | Invoice status |
| amountDue | number | Amount due |
| amountPaid | number | Amount paid |
| created | number | Created timestamp |
| userId | string? | Linked user ID |
| orgId | string? | Linked org ID |
Example App
Check out the full example app in the example/ directory:
git clone https://github.com/get-convex/convex-stripe
cd convex-stripe
npm install
npm run devThe example includes:
- Landing page with product showcase
- One-time payments and subscriptions
- User profile with order history
- Subscription management (cancel, update seats)
- Customer portal integration
- Team/organization billing
Authentication
This component works with any Convex authentication provider. The example uses Clerk:
- Create a Clerk application at clerk.com
- Add
VITE_CLERK_PUBLISHABLE_KEYto your.env.local - Create
convex/auth.config.ts:
export default {
providers: [
{
domain: "https://your-clerk-domain.clerk.accounts.dev",
applicationID: "convex",
},
],
};Troubleshooting
Tables are empty after checkout
Make sure you've:
- Set
STRIPE_SECRET_KEYandSTRIPE_WEBHOOK_SECRETin Convex environment variables - Configured the webhook endpoint in Stripe with the correct events
- Added
invoice.createdandinvoice.finalizedevents (not justinvoice.paid)
"Not authenticated" errors
Ensure your auth provider is configured:
- Create
convex/auth.config.tswith your provider - Run
npx convex devto push the config - Verify the user is signed in before calling actions
Webhooks returning 400/500
Check the Convex logs in your dashboard for errors. Common issues:
- Missing
STRIPE_WEBHOOK_SECRET - Wrong webhook URL (should be
https://<deployment>.convex.site/stripe/webhook) - Missing events in webhook configuration
License
Apache-2.0
