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 paymentsTo roll back later: pnpm run addon disable payments.
What the CLI does
When pnpm run addon enable payments runs, it:
- Renames
.ts.disabled/.tsx.disabledfiles for:packages/backend/convex/stripe.tspackages/backend/convex/entitlements.tspackages/backend/convex/lib/priceToPlan.ts+.test.tscompanion/components/checkout-button.tsxcompanion/components/landing/pricing-section.tsxcompanion/components/dashboard-billing-section.tsx
- Renames
companion/app/_pricing/→companion/app/pricing/so Next.js routes it; renames itspage.tsx.disabled→page.tsx - Inserts the Stripe imports +
app.use(stripe)inconvex.config.ts - Inserts the Stripe webhook route in
http.ts - Inserts the
STRIPE_*schema inenv.ts(Convex) +NEXT_PUBLIC_STRIPE_PRICE_*incompanion/env.ts - Inserts the
<PricingSection />import + JSX in landing - Inserts the "Pricing" entries in
nav+ footer columns ofcompanion/lib/config.ts - Inserts the example
pricingconfig 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":
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
| File | What it does |
|---|---|
packages/backend/convex/convex.config.ts | Registers the Stripe component |
packages/backend/convex/stripe.ts | createCheckout, createPortalSession, cancelSubscription, reactivateSubscription, getMyAccount |
packages/backend/convex/http.ts | registerRoutes(http, components.stripe, { webhookPath: "/stripe/webhook" }) |
packages/backend/convex/entitlements.ts | getMyPlan: derives the user's plan live from synced Stripe data |
companion/components/checkout-button.tsx | Calls the createCheckout action and redirects |
companion/app/dashboard/page.tsx | Reads 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):
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.
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_...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:
Endpoint URL:
https://<your-deployment>.convex.site/stripe/webhook
Subscribe to these events (this is what the component handles):
checkout.session.completedcustomer.created/customer.updatedcustomer.subscription.created/.updated/.deletedinvoice.created/.finalized/.paid/.payment_failedpayment_intent.succeeded/.payment_failed
Save and copy the Signing secret (whsec_...).
Add to the Convex dashboard env:
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:
/pricing.4242 4242 4242 4242 (any future expiry, any CVC)./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
| Card | Behavior |
|---|---|
4242 4242 4242 4242 | Successful charge |
4000 0000 0000 9995 | Declined: insufficient funds |
4000 0000 0000 0341 | Successful, but charge fails after creation (test failed renewals) |
4000 0025 0000 3155 | 3D Secure authentication required |
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 priceId →
STRIPE_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:
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:
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:
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:
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
pnpm exec convex deploy. Note its .convex.site URL.https://<prod-deployment>.convex.site/stripe/webhook with the same events.sk_live_..., the new whsec_..., live STRIPE_PRICE_*.NEXT_PUBLIC_STRIPE_PRICE_* in Vercel prod env to live price IDs.Troubleshooting
Add-ons (optional)
Three opt-in modules (Payments, Email, AI Showcase) that the base boilerplate doesn't register. Turn any subset on with `pnpm run addon enable <id>`.
Email (Resend)
The Email add-on wires the @convex-dev/resend component for transactional sends: welcome email on sign-up plus any custom email your product needs.