Skip to main content
Executor integrates with Stripe for usage-based billing and subscription management.

Overview

The Stripe integration provides:
  • Subscription checkout - Create subscription checkout sessions
  • Customer portal - Manage subscriptions and billing
  • Seat-based billing - Automatic seat quantity synchronization
  • Webhook handling - Real-time subscription updates

Configuration

Environment Variables

# Stripe credentials (required)
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRICE_ID=price_...

# Billing URLs (optional, defaults to localhost:4312)
BILLING_SUCCESS_URL=http://localhost:4312/organization?tab=billing&success=true
BILLING_CANCEL_URL=http://localhost:4312/organization?tab=billing&canceled=true
BILLING_RETURN_URL=http://localhost:4312/organization?tab=billing

Convex Setup

Stripe integration is registered in executor/packages/database/convex/http.ts:
import { registerRoutes as registerStripeRoutes } from "@convex-dev/stripe";
import { components } from "./_generated/api";

registerStripeRoutes(http, components.stripe, {
  webhookPath: "/stripe/webhook",
});

Endpoints

Get Billing Summary

GET /billing/getSummary
Returns billing information for the current organization. Response:
{
  organizationId: string;
  customer: {
    stripeCustomerId: string;
  } | null;
  subscription: {
    stripeSubscriptionId: string;
    stripePriceId: string;
    status: string;
    currentPeriodEnd: number;
    cancelAtPeriodEnd: boolean;
  } | null;
  seats: {
    billableMembers: number;
    desiredSeats: number;
    lastAppliedSeats: number | null;
  };
  sync: {
    status: "ok" | "error" | "pending";
    lastSyncAt: number | null;
    error: string | null;
  };
}

Create Subscription Checkout

POST /billing/createSubscriptionCheckout
Creates a Stripe checkout session for a new subscription. Request:
{
  organizationId: string;
  priceId: string;
  successUrl?: string;
  cancelUrl?: string;
  sessionId?: string;
}
Response:
{
  url: string; // Stripe checkout URL
  sessionId: string;
}
Example:
const checkout = await client.action(
  api.billing.createSubscriptionCheckout,
  {
    organizationId: "org_...",
    priceId: "price_...",
    successUrl: "https://app.example.com/billing/success",
    cancelUrl: "https://app.example.com/billing/cancel",
  }
);

// Redirect to checkout
window.location.href = checkout.url;

Create Customer Portal

POST /billing/createCustomerPortal
Creates a Stripe customer portal session for subscription management. Request:
{
  organizationId: string;
  returnUrl?: string;
  sessionId?: string;
}
Response:
{
  url: string; // Stripe portal URL
}
Example:
const portal = await client.action(
  api.billing.createCustomerPortal,
  {
    organizationId: "org_...",
    returnUrl: "https://app.example.com/organization?tab=billing",
  }
);

// Redirect to portal
window.location.href = portal.url;

Retry Seat Sync

POST /billing/retrySeatSync
Manually trigger seat quantity synchronization. Requirements:
  • Requires billing admin role
  • Organization context from authenticated session
Response:
{
  ok: boolean;
  queued: boolean;
}

Seat-Based Billing

Executor automatically synchronizes subscription seat quantity based on billable organization members.

Seat Calculation

const billableMembers = await db
  .query("organizationMembers")
  .withIndex("by_org_billable_status", (q) =>
    q
      .eq("organizationId", organizationId)
      .eq("billable", true)
      .eq("status", "active")
  )
  .collect();

const seatQuantity = Math.max(1, billableMembers.length);

Automatic Synchronization

Seat quantity is synchronized:
  1. On checkout - Initial quantity set to billable member count
  2. On membership change - Triggered by organization membership events
  3. On subscription update - Stripe webhook updates trigger sync check
  4. Manual retry - Via retrySeatSync endpoint

Sync State Tracking

Executor tracks seat sync state in the billingSeatState table:
{
  organizationId: Id<"organizations">;
  desiredSeats: number;        // Target seat count
  lastAppliedSeats: number;    // Last synced to Stripe
  lastSyncAt: number;          // Timestamp of last sync
  syncError: string | null;    // Error message if sync failed
  syncVersion: number;         // Optimistic locking version
}

Customer Management

Customer Creation

Stripe customers are created automatically on first checkout:
const customer = await stripeClient.createCustomer(ctx, {
  email: account.email,
  name: organization.name,
  metadata: {
    orgId: String(organizationId),
  },
  idempotencyKey: `org:${organizationId}`,
});

await ctx.runMutation(internal.billingInternal.upsertCustomerLink, {
  organizationId,
  stripeCustomerId: customer.customerId,
});

Customer Linking

Customer IDs are cached in the billingCustomers table:
{
  organizationId: Id<"organizations">;
  stripeCustomerId: string;
  createdAt: number;
  updatedAt: number;
}

Subscription Management

Subscription Creation

Checkout sessions create subscriptions with metadata:
const session = await stripeClient.createCheckoutSession(ctx, {
  priceId: "price_...",
  customerId: "cus_...",
  mode: "subscription",
  quantity: billableMembers,
  successUrl: "https://...",
  cancelUrl: "https://...",
  subscriptionMetadata: {
    orgId: String(organizationId),
  },
});

Subscription Updates

Stripe webhooks sync subscription updates to Convex:
// Webhook handler in @convex-dev/stripe component
// Stores subscription in component tables
// Accessible via components.stripe.public.getSubscriptionByOrgId

Authorization

Billing Access Control

function canAccessBilling(role: string): boolean {
  return isAdminRole(role) || canManageBilling(role);
}
Allowed roles:
  • admin - Full organization admin
  • billing_admin - Billing-only admin
  • Custom roles with canManageBilling permission

Access Verification

const access = await ctx.runQuery(
  internal.billingInternal.getBillingAccessForRequest,
  {
    organizationId: "org_...",
    sessionId: "session_...",
  }
);

if (!access || !canAccessBilling(access.role)) {
  throw new Error("Only organization admins can manage billing");
}

Webhook Events

Webhook Endpoint

POST /stripe/webhook
Processed by @convex-dev/stripe component. Signature verification:
const signature = request.headers.get("stripe-signature");
const event = stripe.webhooks.constructEvent(
  payload,
  signature,
  process.env.STRIPE_WEBHOOK_SECRET
);

Supported Events

The Stripe component handles these events automatically:
  • customer.created
  • customer.updated
  • customer.deleted
  • customer.subscription.created
  • customer.subscription.updated
  • customer.subscription.deleted
  • invoice.payment_succeeded
  • invoice.payment_failed

Testing

# Install Stripe CLI
brew install stripe/stripe-cli/stripe

# Login to Stripe
stripe login

# Forward webhooks to local server
stripe listen --forward-to http://localhost:5410/stripe/webhook

# Get webhook signing secret
stripe listen --print-secret
# Set in .env as STRIPE_WEBHOOK_SECRET

# Trigger test events
stripe trigger customer.subscription.created
stripe trigger customer.subscription.updated
Use Stripe test mode keys for development. Test mode transactions don’t charge real money.

Error Handling

Sync Errors

Seat sync errors are tracked in billingSeatState.syncError:
const summary = await client.query(api.billing.getSummary, {});

if (summary.sync.status === "error") {
  console.error("Seat sync failed:", summary.sync.error);
  
  // Retry sync
  await client.mutation(api.billing.retrySeatSync, {});
}

Common Errors

ErrorCauseSolution
No Stripe customer foundCustomer not createdCreate checkout session first
Invalid price IDWrong STRIPE_PRICE_IDVerify price ID in Stripe dashboard
Webhook signature failedWrong STRIPE_WEBHOOK_SECRETUpdate secret from Stripe CLI
Only admins can manage billingInsufficient roleCheck user role in organization

Source Files

  • executor/packages/database/convex/billing.ts - Public billing API
  • executor/packages/database/convex/billingInternal.ts - Internal billing helpers
  • executor/packages/database/convex/billingSync.ts - Seat synchronization logic
  • executor/packages/database/src/billing/public-handlers.ts - Endpoint handlers
  • executor/packages/database/convex/http.ts - Stripe webhook route registration

Build docs developers (and LLMs) love