GlassKit
Add-ons

Payments (Stripe)

Enable the Payments add-on to wire the Convex Stripe component: checkout, customer portal, webhook sync, plan entitlement, pricing UI.

The Payments add-on registers the official @convex-dev/stripe Convex component. The component owns the customers/subscriptions/payments/invoices tables, syncs them from Stripe webhooks, and gives you typed queries you can read from the glasses app and the companion site alike.

You write almost no Stripe code. You enable the add-on, wire keys

  • prices + a webhook URL. The rest is already there.

Off by default

Stripe is one of three opt-in add-ons (see Add-ons for the full list). The base boilerplate doesn't register the Stripe component (no env vars demanded, no /pricing route, no dashboard billing UI) until you enable Payments.

Enable

The shipped CLI is the recommended path. One command renames all the .disabled files back to active and inserts the relevant content into the fenced regions of shared files. You then push your Stripe keys to the Convex dashboard separately.

pnpm run addon enable payments

To roll back later: pnpm run addon disable payments.

What the CLI does

When pnpm run addon enable payments runs, it:

  • Renames .ts.disabled / .tsx.disabled files for:
    • packages/backend/convex/stripe.ts
    • packages/backend/convex/entitlements.ts
    • packages/backend/convex/lib/priceToPlan.ts + .test.ts
    • companion/components/checkout-button.tsx
    • companion/components/landing/pricing-section.tsx
    • companion/components/dashboard-billing-section.tsx
  • Renames companion/app/_pricing/companion/app/pricing/ so Next.js routes it; renames its page.tsx.disabledpage.tsx
  • Inserts the Stripe imports + app.use(stripe) in convex.config.ts
  • Inserts the Stripe webhook route in http.ts
  • Inserts the STRIPE_* schema in env.ts (Convex) + NEXT_PUBLIC_STRIPE_PRICE_* in companion/env.ts
  • Inserts the <PricingSection /> import + JSX in landing
  • Inserts the "Pricing" entries in nav + footer columns of companion/lib/config.ts
  • Inserts the example pricing config block (Pro + Team tiers)
  • Inserts the <DashboardBillingSection /> import + JSX in the dashboard

Selling one-time products instead of subscriptions

The CheckoutButton already accepts mode: "subscription" | "payment". To convert the default subscription example to one-time payments:

Create a one-time product in Stripe

Stripe dashboard → Products → create a Standalone product (not recurring). Copy its price ID.

Update entitlements.getMyPlan to read the payments table

packages/backend/convex/entitlements.ts currently checks active subscriptions. Add a fallback before the return "free":

packages/backend/convex/entitlements.ts
const payments = await ctx.runQuery(
  components.stripe.lib.listPaymentsByUserId,
  { userId },
);
const paid = payments.find((p) => p.status === "succeeded");
if (paid) return planFromPriceId(paid.priceId);
return "free";

Extend the Plan type union

packages/backend/convex/lib/priceToPlan.ts:

export type Plan = "free" | "pro" | "team" | "lifetime";

Add a new STRIPE_PRICE_LIFETIME env var, declare it in packages/backend/convex/env.ts, push to Convex dashboard.

Pass mode="payment" in the pricing UI

companion/components/landing/pricing-section.tsx:

<CheckoutButton priceId={tier.priceId} mode="payment">
  {tier.cta}
</CheckoutButton>

If you mix subscription + one-time tiers, add a mode field to each tier in companion/lib/config.ts.

What ships in the box

FileWhat it does
packages/backend/convex/convex.config.tsRegisters the Stripe component
packages/backend/convex/stripe.tscreateCheckout, createPortalSession, cancelSubscription, reactivateSubscription, getMyAccount
packages/backend/convex/http.tsregisterRoutes(http, components.stripe, { webhookPath: "/stripe/webhook" })
packages/backend/convex/entitlements.tsgetMyPlan: derives the user's plan live from synced Stripe data
companion/components/checkout-button.tsxCalls the createCheckout action and redirects
companion/app/dashboard/page.tsxReads getMyAccount for plan + billing history

The component model means: a developer cloning the boilerplate sets keys and price IDs; they don't write checkout, webhook, or sync code. Everything Stripe-shaped is already there.

Wiring it up

Create Stripe products and prices

dashboard.stripe.com/register: sign up with your business email. You'll be in test mode by default; stay there until the full flow works.

For each tier you want to sell (the boilerplate ships with example Pro and Team tiers, adjust to your product):

Products → Add product
Recurring, USD, your chosen price
Save and copy the Price ID (price_...)

API keys

Stripe keys go in the Convex dashboard, not a local .env. Convex functions run in Convex's cloud.

Stripe → Developers → API keys → copy the Secret key (sk_test_...).

Open your Convex dashboard → Settings → Environment Variables.

Add the secret + your price-to-plan mapping. The companion needs the same price IDs on the client to wire the checkout buttons.

Convex environment variables
STRIPE_SECRET_KEY=sk_test_...

# Map your real price IDs to plan names (used by entitlements.ts).
STRIPE_PRICE_PRO=price_...
STRIPE_PRICE_TEAM=price_...
companion/.env.local
NEXT_PUBLIC_STRIPE_PRICE_PRO=price_...
NEXT_PUBLIC_STRIPE_PRICE_TEAM=price_...

(These are public: price_... IDs aren't secret.)

The webhook URL

The Stripe component serves its webhook at https://<deployment>.convex.site/stripe/webhook. Your deployment name is the part before .convex.cloud in your Convex URL.

In the Stripe dashboard:

Developers → Webhooks → Add endpoint

Endpoint URL: https://<your-deployment>.convex.site/stripe/webhook

Subscribe to these events (this is what the component handles):

  • checkout.session.completed
  • customer.created / customer.updated
  • customer.subscription.created / .updated / .deleted
  • invoice.created / .finalized / .paid / .payment_failed
  • payment_intent.succeeded / .payment_failed

Save and copy the Signing secret (whsec_...).

Add to the Convex dashboard env:

Convex environment variables
STRIPE_WEBHOOK_SECRET=whsec_...

No Stripe CLI needed

The webhook URL is your Convex deployment, so Stripe reaches it directly, from anywhere. Local Stripe CLI forwarding is a Next.js-routes pattern; the component skips it.

Test the checkout flow

With pnpm exec convex dev running in packages/backend/ and pnpm --filter @glasskit/companion dev running on :3000:

Sign up on the companion (Clerk).
Click a tier on /pricing.
Use test card 4242 4242 4242 4242 (any future expiry, any CVC).
You land back on /dashboard?checkout=success.

Inside Convex you'll see the events flow into the component's tables. api.entitlements.getMyPlan updates reactively. Refresh the dashboard and the plan should read pro / team.

Useful test cards

CardBehavior
4242 4242 4242 4242Successful charge
4000 0000 0000 9995Declined: insufficient funds
4000 0000 0000 0341Successful, but charge fails after creation (test failed renewals)
4000 0025 0000 31553D Secure authentication required

Full Stripe test card reference.

How entitlement works

There is no plan column on users. entitlements.getMyPlan reads the Stripe component's synced subscriptions and maps the active subscription's priceIdSTRIPE_PRICE_PRO"pro". Convex queries are reactive, so the moment a webhook updates a subscription, every client's plan updates.

Unknown / unset price IDs fall back to "free". The mapping lives in packages/backend/convex/entitlements.ts. Edit PRICE_TO_PLAN there if you add tiers.

Customer portal

stripe.createPortalSession redirects the user to Stripe's hosted portal where they can update card, cancel, or download invoices. Enable the portal in Stripe first:

Settings → Billing → Customer portal → toggle on
Configure what users can do (cancel, update card, etc.)
Save

The dashboard's Manage billing button calls api.stripe.createPortalSession and redirects.

One-time payments

createCheckout already takes mode: "subscription" | "payment". For one-time charges, pass "payment":

<CheckoutButton priceId={priceId} mode="payment">
  Buy lifetime access
</CheckoutButton>

The component will create a payment_intent instead of a recurring subscription; the synced data shows up in getMyAccount().payments.

Selling YOUR product (not just the example tiers)

The Stripe wiring above is the boilerplate's example checkout: Pro and Team tiers shipped as a working reference. To sell your own product to your glasses users, you reuse the same component, add your own price IDs, and extend entitlements.ts.

Add your real products in Stripe

Replace or supplement the example Pro / Team products. Whatever your tiers are (solo, pro, pro_annual, a one-time lifetime, a per-seat metered plan), Stripe handles it. Copy each price_... ID.

Map your prices to plan names

Edit packages/backend/convex/entitlements.ts. The default mapping looks like this:

packages/backend/convex/entitlements.ts
const PRICE_TO_PLAN: Record<string, Plan> = {
  [process.env.STRIPE_PRICE_PRO ?? ""]: "pro",
  [process.env.STRIPE_PRICE_TEAM ?? ""]: "team",
};

Add your own env-var-keyed entries. Use whatever plan names map cleanly to gates in your UI:

packages/backend/convex/entitlements.ts
const PRICE_TO_PLAN: Record<string, Plan> = {
  [process.env.STRIPE_PRICE_SOLO ?? ""]: "solo",
  [process.env.STRIPE_PRICE_PRO ?? ""]: "pro",
  [process.env.STRIPE_PRICE_PRO_ANNUAL ?? ""]: "pro",
  [process.env.STRIPE_PRICE_LIFETIME ?? ""]: "pro",
};

Add the matching env vars in the Convex dashboard. The schema validator in packages/backend/convex/env.ts enforces them at deploy time, so add the keys to the zod schema there too. See env for the pattern.

Update the Plan type

In packages/backend/convex/entitlements.ts, broaden the Plan union to include your plan names:

packages/backend/convex/entitlements.ts
export type Plan = "free" | "solo" | "pro";

Every consumer of getMyPlan will get type errors against any gates that don't account for the new plans: exactly the prompt-on-rename behavior you want.

Build your pricing UI

The companion's app/(web)/pricing/page.tsx is an example: replace its tier list with your own. Each <CheckoutButton priceId={...}> opens Stripe Checkout for that price; the component handles auth, customer-create, return URLs, and reactive entitlement updates.

For one-time products (lifetime, credit packs, in-glasses purchases), pass mode="payment" instead of "subscription".

Gate features by plan

Anywhere in your code, from either app:

import { useQuery } from "convex/react";
import { api } from "@glasskit/backend/convex/_generated/api";

function CoolFeature() {
  const { plan } = useQuery(api.entitlements.getMyPlan) ?? { plan: "free" };
  if (plan === "free") return <UpgradeNudge />;
  return <TheActualFeature />;
}

The query is reactive. A webhook-driven plan change flips the component automatically: no polling, no client-side cache to invalidate.

For server-side gates in the companion (route handlers, server actions), use fetchQuery from convex/nextjs with the user's Clerk token. For backend-internal gates (Convex actions checking their own caller's plan), call entitlements.loadPlan(ctx, userId) directly.

What you don't need to touch

  • The webhook handler (http.ts): already mounts the component's routes
  • The customer portal (stripe.createPortalSession): works with any Stripe customer
  • Cancel / reactivate / billing history (getMyAccount): generic, plan-agnostic
  • The Clerk → Convex auth bridge: same as for any other query

This is the leverage of the component model: your product code is plan names + a UI; everything between Stripe and your component is boilerplate the component already owns.

Going live

Activate your Stripe account: business details, bank account.
Recreate products + prices in live mode: they don't carry over.
Production Convex deployment: pnpm exec convex deploy. Note its .convex.site URL.
Live webhook at https://<prod-deployment>.convex.site/stripe/webhook with the same events.
Swap keys in prod Convex env: sk_live_..., the new whsec_..., live STRIPE_PRICE_*.
Swap NEXT_PUBLIC_STRIPE_PRICE_* in Vercel prod env to live price IDs.
Test with a real card and refund yourself.

Troubleshooting

On this page