name: stripe description: > Stripe billing patterns for spiceflow + Drizzle apps. Covers creating products and prices via the Stripe CLI with stable lookup keys, multi-currency USD+EUR pricing, monthly/yearly subscriptions, type-safe Checkout and Billing Portal integration in spiceflow routes, webhook handling, and the rules for preventing double customers and double subscriptions in the database. Load this skill whenever adding, modifying, or debugging any Stripe code (prices, checkout sessions, portal sessions, webhooks, subscription logic).
Stripe
Use Stripe Checkout for new purchases and the Stripe Billing Portal for subscription management (upgrade, downgrade, cancel, switch monthly↔yearly). Do not build a custom billing UI.
Core rules, in priority order:
- One Stripe customer per
Org. Store the customer id inOrg.stripeCustomerIdand reuse it on every checkout/portal call. - Prefer
lookup_keyover hardcodedprice_xxxids. Fetch prices at runtime when possible. - Every Price uses
currency_optionsfor EUR on top of a USD base. Same integer value for both — see Multi-currency. - One active Subscription row per
Org. Before creating a checkout session, check the DB and redirect existing subscribers to the portal instead. - All Stripe-facing HTTP code lives inside spiceflow sub-apps (
website/src/lib/spiceflow-*.tsx). Not react-router actions. The webhook route is also a spiceflow route — spiceflow handlers receive a standardRequestobject, soawait request.text()gives the raw body needed for Stripe's signature verification. - Return errors as values, never throw. All Stripe/Drizzle calls are wrapped with
.catch()into tagged errore errors (StripeApiError,DbError,PriceNotFoundError, etc.).constructEventand other sync-throwing APIs go througherrore.try. Handlers checkinstanceof Error, early-return, and map errors to HTTP responses viaerrore.matchErrorat the HTTP boundary only. Always read the errore skill before writing or modifying error handling code in Stripe routes — it covers tagged errors,.catch()boundary rules, flat control flow, cause chains, and thematchErrorexhaustive handler.
CLI auth and multiple accounts
The Stripe CLI stores credentials in ~/.config/stripe/config.toml. By default, stripe login writes to a [default] section and all commands use it.
For multiple Stripe accounts (e.g. separate stores, test vs prod, client projects), use --project-name to namespace each one:
stripe login --project-name=myapp-prod
stripe login --project-name=myapp-sandbox
This creates separate sections in config.toml. Then pass --project-name on every command to target the right account:
stripe products create --name="Pro" --project-name=myapp-prod
stripe listen --forward-to http://localhost:8040/api/webhook --project-name=myapp-prod
Without --project-name, the CLI uses [default]. All CLI commands in this skill omit it for brevity; add it when working with multiple accounts.
Env vars
For Vite + spiceflow apps (not Next.js): Vite does not expose process.env to client code — in the browser you must use import.meta.env.VITE_*, and only variables prefixed VITE_ are inlined into the client bundle. On the server (Node.js) process.env works normally.
Only three Stripe env vars should exist. The publishable key is the only one that runs in the browser:
# .env.local — committed to neither git nor the client bundle (server-only for the two secrets)
STRIPE_SECRET_KEY=sk_test_... # server only, via process.env
STRIPE_WEBHOOK_SECRET=whsec_... # server only, via process.env
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_... # client + server, via import.meta.env on the client
// website/src/lib/env.ts — server-side accessors
export const env = {
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
// Exposed so server code can also read it without reaching into import.meta.env
VITE_STRIPE_PUBLISHABLE_KEY: process.env.VITE_STRIPE_PUBLISHABLE_KEY,
}
// Client-side code — React components, "use client" files, anything that ends up in the browser bundle
const publishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY
Never use process.env.* in client code. Vite will either leave it as the literal string process.env.X at runtime (which explodes as ReferenceError: process is not defined) or silently strip it. Only import.meta.env.VITE_* is safe in the browser.
Never prefix STRIPE_SECRET_KEY or STRIPE_WEBHOOK_SECRET with VITE_. Anything starting with VITE_ is inlined into the client bundle and visible in devtools. If you accidentally rename the secret key to VITE_STRIPE_SECRET_KEY, you leak it to every visitor.
Prefer lookup_key over STRIPE_PRICE_ID_FOO env vars for each plan. Env vars bind code to a specific Stripe account at deploy time. With lookup_key the code stays identical across accounts.
TypeScript types for import.meta.env
Add to website/src/vite-env.d.ts (or wherever the existing Vite env declaration lives):
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_STRIPE_PUBLISHABLE_KEY: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
This gives autocomplete on import.meta.env.VITE_* and fails compilation if you typo the name.
How to get each value
| Var | Where from | Notes |
|---|---|---|
STRIPE_SECRET_KEY |
stripe login → cat ~/.config/stripe/config.toml (restricted key rk_* works for most dev), or copy sk_test_/sk_live_ from https://dashboard.stripe.com/apikeys once |
Server only. Store in doppler / .env.local, never in the repo |
STRIPE_WEBHOOK_SECRET |
Dev: stripe listen --print-secret. Prod: returned once from stripe webhook_endpoints create ... |
Server only. Returned only on endpoint creation — capture it |
VITE_STRIPE_PUBLISHABLE_KEY |
Copy pk_test_/pk_live_ from https://dashboard.stripe.com/apikeys |
Safe to ship to the browser. Must be VITE_-prefixed so Vite inlines it into the client bundle |
Local dev webhook loop
# Terminal 1 — run the site
pnpm dev
# Terminal 2 — forward Stripe events to the local webhook route
stripe listen --forward-to http://localhost:8040/api/stripe/webhooks
Copy the whsec_... it prints and set STRIPE_WEBHOOK_SECRET in .env.local. The secret is stable across restarts for the same machine.
Creating products and prices via CLI
Prefer lookup_key so app code references a stable string instead of a generated price_xxx id. This makes it safe to rotate prices, migrate accounts, or change numbers without redeploys.
Naming convention
<tier>_<interval> — e.g. pro_monthly, pro_yearly, team_monthly, team_yearly. Keep tier names generic and decoupled from product branding so you can rename the product without rotating price lookup keys.
Create a product once
Run this first. Copy the id from the JSON response into a shell variable before moving to the next step:
stripe products create \
--name="Pro" \
--description="Pro plan with full features"
Output includes "id": "prod_SomeRealId". Now set the placeholder for the following commands:
export PRODUCT_ID=prod_SomeRealId # replace with the id from above
The returned prod_xxx id doesn't need to leak into code — we look up by product via its prices' lookup_key.
Create prices with monthly+yearly and USD+EUR
Run each of the two commands below separately, one at a time. After each one, confirm the response contains the expected lookup_key and unit_amount before moving on. $PRODUCT_ID is a placeholder — replace it with the id you exported above.
Set tax_behavior=exclusive on BOTH the top-level (USD) and the EUR currency_options entry — otherwise EUR defaults to unspecified and the mismatch can block portal plan-switching.
# 1. Monthly — $10/mo + €10/mo
stripe prices create \
--product=$PRODUCT_ID \
--currency=usd \
--unit-amount=1000 \
-d "recurring[interval]=month" \
-d "currency_options[eur][unit_amount]=1000" \
-d "currency_options[eur][tax_behavior]=exclusive" \
-d "lookup_key=pro_monthly" \
-d "nickname=Pro Monthly" \
-d "tax_behavior=exclusive"
Check the response, then run the next:
# 2. Yearly — $100/yr + €100/yr
stripe prices create \
--product=$PRODUCT_ID \
--currency=usd \
--unit-amount=10000 \
-d "recurring[interval]=year" \
-d "currency_options[eur][unit_amount]=10000" \
-d "currency_options[eur][tax_behavior]=exclusive" \
-d "lookup_key=pro_yearly" \
-d "nickname=Pro Yearly" \
-d "tax_behavior=exclusive"
After both succeed, verify the catalog. Pass --expand "data.currency_options" or the EUR amounts will NOT show in the output (Stripe omits currency_options unless explicitly expanded):
stripe prices list \
--lookup-keys pro_monthly \
--lookup-keys pro_yearly \
--expand "data.currency_options"
You should see both prices. If one is missing, create the missing one manually — do not re-run the whole block or you'll get "lookup_key already exists" errors.
Adding a second tier (e.g.
team): create a new product viastripe products create --name="Team" ..., then two more prices with lookup keysteam_monthlyandteam_yearly. Add both new price ids to the portal configuration under a secondproducts[1]entry. The pattern scales linearly.
Multi-currency
USD is the base currency (top-level currency field on the Price). EUR is added via currency_options[eur][unit_amount]. We intentionally use the same integer value for both currencies — at typical EUR/USD rates this captures ~8–10% extra margin on EUR customers with zero code changes.
unit_amountis in cents (zero-decimal currencies like JPY use whole units).- Stripe picks the currency at checkout time based on the customer's
preferred_localesor an explicitcurrencyon the Checkout Session. Once a subscription is created, the currency is locked — the portal cannot switch currencies. This is fine: EUR users stay on EUR, USD users stay on USD.
Monthly ↔ yearly switching
Customers upgrade/downgrade between _monthly and _yearly via the Billing Portal, not custom code. The portal supports this only if both prices belong to the same product. The portal configuration (see Portal configuration) lists both prices under the single product.
Rotating a price
To change the price amount without losing the lookup key binding. Run these one at a time, reading the output between each step. $NEW_PRICE and $OLD_PRICE are placeholder shell variables — set them manually from the actual ids Stripe returns.
Step 1 — Create the new price without the lookup_key:
stripe prices create \
--product=$PRODUCT_ID \
--currency=usd \
--unit-amount=1200 \
-d "recurring[interval]=month" \
-d "currency_options[eur][unit_amount]=1200" \
-d "nickname=Pro Monthly v2" \
-d "tax_behavior=exclusive"
Copy the returned id into NEW_PRICE:
export NEW_PRICE=price_NewIdFromAbove
Step 2 — Atomically transfer the lookup_key from the old price to the new one:
stripe prices update $NEW_PRICE \
-d "lookup_key=pro_monthly" \
-d "transfer_lookup_key=true"
Confirm the response shows "lookup_key": "pro_monthly" on the new price.
Step 3 — Find the old price id and deactivate it. Use stripe prices list to find it if you don't already have it:
export OLD_PRICE=price_OldIdYouLookedUp
stripe prices update $OLD_PRICE -d active=false
Existing subscriptions stay on the old price. New checkouts use the new price. No redeploy needed.
If Step 2 fails (for example because transfer_lookup_key isn't supported on that price type), the old price still owns the lookup key and nothing is broken — you can safely delete the dangling new price via stripe prices update $NEW_PRICE -d active=false and retry.
Single customer per Org
Rule: create a Stripe Customer once per Org, store its id in Org.stripeCustomerId, reuse it forever. This is the single biggest lever for preventing duplicate customers, duplicate subscriptions, and broken portal sessions.
// website/src/lib/stripe.ts
import * as orm from 'drizzle-orm'
import { db, schema } from 'db'
import * as errore from 'errore'
import {
stripe,
StripeApiError,
} from 'website/src/lib/stripe'
export class DbError extends errore.createTaggedError({
name: 'DbError',
message: 'Database operation failed: $operation',
}) {}
export class OrgNotFoundError extends errore.createTaggedError({
name: 'OrgNotFoundError',
message: 'Org $orgId not found',
}) {}
/**
* Get or create the Stripe customer for an org. Idempotent — safe to call
* from any flow. This is the ONLY place where
* `stripe.customers.create` should be called.
*/
export async function getOrCreateStripeCustomer({
orgId,
email,
}: {
orgId: string
email: string | null | undefined
}) {
const org = await db.query.orgs
.findFirst({ where: { orgId } })
.catch((e) => new DbError({ operation: 'orgs.findFirst', cause: e }))
if (org instanceof Error) return org
if (!org) return new OrgNotFoundError({ orgId })
if (org.stripeCustomerId) return org.stripeCustomerId
const customer = await stripe.customers
.create({
email: email || undefined,
metadata: { orgId },
})
.catch((e) => new StripeApiError({ operation: 'customers.create', cause: e }))
if (customer instanceof Error) return customer
const updated = await db
.update(schema.orgs)
.set({ stripeCustomerId: customer.id })
.where(orm.eq(schema.orgs.orgId, orgId))
.catch((e) => new DbError({ operation: 'orgs.update', cause: e }))
if (updated instanceof Error) return updated
return customer.id
}
Every caller receives string | DbError | OrgNotFoundError | StripeApiError and must handle the failure modes explicitly:
const customerId = await getOrCreateStripeCustomer({ orgId, email })
if (customerId instanceof Error) return customerId
Never call stripe.customers.create anywhere else. Never pass customer_email to Checkout without also checking for an existing stripeCustomerId first — that creates a second customer row in Stripe on repeat purchases and the portal breaks (each customer has its own separate subscriptions).
Server actions
Checkout and portal flows are server actions, not API routes. Server actions are simpler: no route definition, no errorToResponse mapper, no separate client file. They auto re-render the page after completing and use redirect() to navigate to Stripe URLs.
The webhook must stay as a spiceflow .post() route because Stripe sends raw HTTP POST requests with signature headers. Server actions are browser-only with CSRF origin checks.
// src/actions/billing.tsx
'use server'
import { db, schema } from 'db'
import { env } from 'src/lib/env'
import {
stripe,
getOrCreateStripeCustomer,
} from 'src/lib/stripe'
import { getSession } from 'src/lib/auth'
import { redirect } from 'spiceflow'
/**
* Start a Checkout Session for a new subscription, or redirect to the
* portal if the org already has one. Prevents double subscriptions by
* checking the DB before creating a session.
*/
export async function startCheckout(priceId: string, returnPath = '/billing') {
const session = await getSession()
if (!session) throw new Error('Unauthorized')
const { orgId, email } = session
const customerId = await getOrCreateStripeCustomer({ orgId, email })
if (customerId instanceof Error) throw customerId
// If already subscribed, short-circuit to the portal
const existing = await db.query.subscriptions.findFirst({
where: {
orgId,
status: { in: ['active', 'trialing', 'past_due'] },
},
})
if (existing) {
const portal = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: new URL(returnPath, env.PUBLIC_URL).toString(),
})
throw redirect(portal.url)
}
const checkoutSession = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
success_url: new URL(returnPath, env.PUBLIC_URL).toString(),
cancel_url: new URL(returnPath, env.PUBLIC_URL).toString(),
allow_promotion_codes: true,
client_reference_id: orgId,
// Metadata on BOTH the session and the subscription so webhooks
// can always resolve orgId regardless of event type.
metadata: { orgId },
subscription_data: { metadata: { orgId } },
})
if (!checkoutSession.url) throw new Error('Checkout session has no URL')
throw redirect(checkoutSession.url)
}
/**
* Open the Billing Portal for an existing customer. Used by the
* "Manage subscription" button.
*/
export async function openPortal(returnPath = '/billing') {
const session = await getSession()
if (!session) throw new Error('Unauthorized')
const { orgId, email } = session
const customerId = await getOrCreateStripeCustomer({ orgId, email })
if (customerId instanceof Error) throw customerId
const portal = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: new URL(returnPath, env.PUBLIC_URL).toString(),
})
throw redirect(portal.url)
}
Server action errors are caught by the nearest ErrorBoundary. If getOrCreateStripeCustomer returns an error, throwing it propagates to the client with a sanitized message.
Billing page
Use .loader() to fetch subscription data server-side, and useLoaderData() in client components to read it without prop drilling.
// src/main.tsx
import { Spiceflow } from 'spiceflow'
import { db } from 'db'
import { getSession } from 'src/lib/auth'
import { BillingPage } from './app/billing-page'
export const app = new Spiceflow()
// ... layout, other pages ...
.loader('/billing', async ({ request, redirect }) => {
const session = await getSession(request)
if (!session) throw redirect('/login')
const subscription = await db.query.subscriptions.findFirst({
where: {
orgId: session.orgId,
status: { in: ['active', 'trialing', 'past_due'] },
},
})
return { subscription, orgId: session.orgId }
})
.page('/billing', async () => {
return <BillingPage />
})
declare module 'spiceflow/react' {
interface SpiceflowRegister { app: typeof app }
}
The client component reads loader data via useLoaderData and calls server actions directly:
// src/app/billing-page.tsx
'use client'
import { useLoaderData } from 'spiceflow/react'
import { startCheckout, openPortal } from '../actions/billing'
export function BillingPage() {
const { subscription } = useLoaderData('/billing')
if (subscription) {
return (
<div>
<h1>Your Plan</h1>
<p>Status: {subscription.status}</p>
<p>Plan: {subscription.variantName}</p>
<button onClick={() => openPortal()}>
Manage Subscription
</button>
</div>
)
}
return (
<div>
<h1>Choose a Plan</h1>
<button onClick={() => startCheckout('price_pro_monthly')}>
Pro Monthly
</button>
<button onClick={() => startCheckout('price_pro_yearly')}>
Pro Yearly
</button>
</div>
)
}
Notes on the pattern:
- Server actions use
throw redirect(url)to navigate to Stripe URLs. Since every server action triggers a page re-render, usingredirectavoids flashing the re-rendered current page before navigating. - Loader data stays fresh. After a server action completes, the page re-renders with fresh loader data automatically. No manual
router.refresh()needed. useLoaderData('/billing')is type-safe. TypeScript infers the return type from the loader registered at that path. If you rename a field in the loader, every component that reads it gets a compile error.- No
errorToResponsemapper needed. Server actions throw errors directly; the nearestErrorBoundarycatches them. No HTTP status code mapping required. - No separate client file. Import server actions directly from
'use server'files into client components. NocreateSpiceflowFetchwrapper needed for these flows.
Webhook handler
The webhook is a spiceflow route (not a server action). Stripe sends raw HTTP POST requests with signature headers, so it needs a proper endpoint. Spiceflow handlers receive a standard Web Request, so await request.text() gives you the exact raw body bytes that Stripe signed — which is what stripe.webhooks.constructEvent needs for signature verification.
Do not parse the body with await request.json() before verifying the signature. JSON parsing normalizes whitespace and key order, which breaks the HMAC check. Always call await request.text() first.
The handler uses the errore pattern: constructEvent is a throwing sync API, so wrap it with errore.try. Every DB write is a .catch() boundary with a tagged error. Handler dispatch is a sequence of early returns, no try/catch for control flow.
// src/lib/stripe-webhook.tsx
import { Spiceflow } from 'spiceflow'
import * as errore from 'errore'
import {
stripe,
handleCheckoutSessionCompleted,
handleSubscriptionChange,
} from 'src/lib/stripe'
import { env } from 'src/lib/env'
import { notifyError } from 'src/lib/errors'
export class WebhookSignatureError extends errore.createTaggedError({
name: 'WebhookSignatureError',
message: 'Stripe webhook signature verification failed',
}) {}
export const webhookApp = new Spiceflow({ basePath: '/api/stripe' })
.post('/webhook', async ({ request }) => {
const sig = request.headers.get('stripe-signature')
if (!sig) return new Response('No signature', { status: 400 })
// spiceflow exposes the full raw request body via request.text().
// Must be called BEFORE any other body parsing — reading the stream
// twice is an error.
const rawBody = await request.text()
// constructEvent is a throwing SYNC API — wrap it with errore.try
// rather than a try/catch statement.
const event = errore.try({
try: () =>
stripe.webhooks.constructEvent(
rawBody,
sig,
env.STRIPE_WEBHOOK_SECRET!,
),
catch: (e) => new WebhookSignatureError({ cause: e }),
})
if (event instanceof Error) {
notifyError(event, 'Stripe webhook signature')
return new Response('Bad signature', { status: 400 })
}
const result = await (async () => {
if (event.type === 'checkout.session.completed') {
return handleCheckoutSessionCompleted(event.data.object)
}
if (
event.type === 'customer.subscription.created' ||
event.type === 'customer.subscription.updated' ||
event.type === 'customer.subscription.deleted'
) {
return handleSubscriptionChange(event.data.object)
}
return null // unhandled event type — ignore silently
})()
if (result instanceof Error) {
notifyError(result, `Stripe webhook ${event.type}`)
return new Response('Webhook failed', { status: 500 })
}
return new Response('ok', { status: 200 })
})
And the dispatchers in src/lib/stripe.ts, following the same errore conventions:
// src/lib/stripe.ts
import Stripe from 'stripe'
import * as orm from 'drizzle-orm'
import * as errore from 'errore'
import { db, schema } from 'db'
import { stripe, StripeApiError, DbError } from 'src/lib/stripe'
export class OrgResolutionError extends errore.createTaggedError({
name: 'OrgResolutionError',
message: 'Could not resolve orgId for Stripe event: $context',
}) {}
export async function handleCheckoutSessionCompleted(
session: Stripe.Checkout.Session,
) {
// Always re-fetch — event payload can be stale in out-of-order delivery.
const latest = await stripe.checkout.sessions
.retrieve(session.id, { expand: ['line_items'] })
.catch(
(e) =>
new StripeApiError({
operation: 'checkout.sessions.retrieve',
cause: e,
}),
)
if (latest instanceof Error) return latest
const customerEmail = latest.customer_details?.email || null
const orgId = await resolveStripeOrgId({
metadataOrgId: latest.metadata?.orgId,
customerId: typeof latest.customer === 'string' ? latest.customer : latest.customer?.id ?? null,
customerEmail,
context: `checkout.session.completed (${latest.id})`,
})
if (orgId instanceof Error) return orgId
if (orgId === null) return null // unroutable webhook — drop it
const item = latest.line_items?.data[0]
if (!item || !item.price?.id) return null // nothing to record
const record: typeof schema.paymentForCredits.$inferInsert = {
id: latest.id,
email: customerEmail || '',
variantName: item.description || '',
orderId: latest.id,
productId: item.price.product.toString(),
variantId: item.price.id,
provider: 'stripe',
orgId,
metadata: latest.metadata || {},
}
const upsertResult = await db
.insert(schema.paymentForCredits)
.values(record)
.onConflictDoUpdate({
target: schema.paymentForCredits.id,
set: record,
})
.catch(
(e) => new DbError({ operation: 'paymentForCredits.upsert', cause: e }),
)
if (upsertResult instanceof Error) return upsertResult
return null
}
export async function handleSubscriptionChange(
subscription: Stripe.Subscription,
) {
const latest = await stripe.subscriptions
.retrieve(subscription.id)
.catch(
(e) =>
new StripeApiError({
operation: 'subscriptions.retrieve',
cause: e,
}),
)
if (latest instanceof Error) return latest
const metadataEmail = latest.metadata?.email || null
const orgId = await resolveStripeOrgId({
metadataOrgId: latest.metadata?.orgId,
customerId: typeof latest.customer === 'string' ? latest.customer : null,
customerEmail: metadataEmail,
context: `customer.subscription event (${latest.id})`,
})
if (orgId instanceof Error) return orgId
if (orgId === null) return null
const firstItem = latest.items.data[0]
if (!firstItem) {
return new OrgResolutionError({
context: `No items in subscription ${latest.id}`,
})
}
const record: typeof schema.subscriptions.$inferInsert = {
orgId,
orderId: latest.id,
productId: firstItem.price.product.toString(),
variantId: firstItem.price.id,
subscriptionId: latest.id,
email: metadataEmail || undefined,
status: latest.status,
variantName: firstItem.price.nickname || undefined,
createdAt: new Date(latest.created * 1000),
metadata: latest.metadata || {},
provider: 'stripe',
customerId: latest.customer.toString(),
}
const upsertResult = await db
.insert(schema.subscriptions)
.values(record)
.onConflictDoUpdate({
target: [schema.subscriptions.subscriptionId, schema.subscriptions.variantId],
set: record,
})
.catch(
(e) => new DbError({ operation: 'subscriptions.upsert', cause: e }),
)
if (upsertResult instanceof Error) return upsertResult
return null
}
async function resolveStripeOrgId({
metadataOrgId,
customerId,
customerEmail,
context,
}: {
metadataOrgId: string | undefined
customerId: string | null
customerEmail: string | null
context: string
}) {
// 1. Primary path — metadata.orgId from the checkout session or subscription
if (!metadataOrgId) {
console.warn(`No orgId in Stripe metadata for ${context}`)
}
if (metadataOrgId) {
const org = await db.query.orgs
.findFirst({ where: { orgId: metadataOrgId } })
.catch((e) => new DbError({ operation: 'orgs.findFirst', cause: e }))
if (org instanceof Error) return org
if (org) return org.orgId
console.warn(`Stripe webhook unknown orgId ${metadataOrgId} for ${context}`)
}
// 2. Fallback — metadata.orgId on the Stripe customer object itself.
// getOrCreateStripeCustomer writes orgId into the customer's metadata
// at creation time, so this is always set for customers we created.
if (customerId) {
const customer = await stripe.customers
.retrieve(customerId)
.catch(
(e) =>
new StripeApiError({ operation: 'customers.retrieve', cause: e }),
)
if (customer instanceof Error) return customer
if (!customer.deleted) {
const customerOrgId = customer.metadata?.orgId
if (customerOrgId) {
const org = await db.query.orgs
.findFirst({ where: { orgId: customerOrgId } })
.catch(
(e) => new DbError({ operation: 'orgs.findFirst', cause: e }),
)
if (org instanceof Error) return org
if (org) return org.orgId
}
}
}
// 3. Last resort — match customer email to a known user's first org
if (!customerEmail) return null
const user = await db.query.users
.findFirst({
where: { email: customerEmail },
with: { orgs: true },
})
.catch((e) => new DbError({ operation: 'users.findFirst', cause: e }))
if (user instanceof Error) return user
return user?.orgs?.[0]?.orgId ?? null
}
Do not add a Zod body schema to the webhook route. Zod would try to parse the body as JSON, which either double-consumes the stream or normalizes whitespace and breaks HMAC verification. The raw-text handler above is the only correct pattern.
Register the webhook URL with Stripe once the route is mounted. Create one endpoint per deployed environment — production and preview have different hostnames, so they need separate endpoints, each with its own whsec_ stored in that environment's secrets. Scope enabled_events to only what your handler processes (the handler silently drops unknown types, but a tight list keeps the dashboard clean):
# Production
stripe webhook_endpoints create \
--api-key "$STRIPE_SECRET_KEY" \
--url="https://your-site.example/api/stripe/webhook" \
-d "enabled_events[]=customer.subscription.created" \
-d "enabled_events[]=customer.subscription.updated" \
-d "enabled_events[]=customer.subscription.deleted"
# Preview (separate endpoint, separate secret)
stripe webhook_endpoints create \
--api-key "$STRIPE_SECRET_KEY" \
--url="https://preview.your-site.example/api/stripe/webhook" \
-d "enabled_events[]=customer.subscription.created" \
-d "enabled_events[]=customer.subscription.updated" \
-d "enabled_events[]=customer.subscription.deleted"
Always pass
--api-key "$STRIPE_SECRET_KEY"explicitly. Without it, the CLI falls back to the[default]profile in~/.config/stripe/config.toml— which may point at a different account (and the interactivedeleteconfirmation banner shows that wrong account's name/mode). Passing the key from your secrets manager guarantees every command hits the intended account.
For local development, use stripe listen --forward-to http://localhost:8040/api/stripe/webhook and use its whsec_ instead.
The webhook secret is shown ONLY on create — capture it immediately
The secret field (whsec_...) is returned only in the webhook_endpoints create response. webhook_endpoints retrieve and update return the secret masked/absent — there is no way to read it back later. If you miss it, you must delete the endpoint and recreate it (or roll the secret in the Dashboard).
So capture it in the same command that creates the endpoint, and pipe it straight into your secrets manager via stdin so the value never lands in shell history, logs, or an agent's context:
# Create + capture the secret directly into the secrets manager (sigillo example).
# The whsec_ value flows create -> stdin -> set, never printed.
resp=$(stripe webhook_endpoints create \
--api-key "$STRIPE_SECRET_KEY" \
--url="https://your-site.example/api/stripe/webhook" \
-d "enabled_events[]=customer.subscription.created" \
-d "enabled_events[]=customer.subscription.updated" \
-d "enabled_events[]=customer.subscription.deleted")
echo "$resp" | python3 -c 'import json,sys; sys.stdout.write(json.load(sys.stdin)["secret"])' \
| sigillo secrets set STRIPE_WEBHOOK_SECRET -c prod
After storing it, sync to your deploy target (wrangler secret put STRIPE_WEBHOOK_SECRET, etc.) per environment.
Idempotency on upserts
Webhooks are delivered at-least-once. Every write must be an upsert keyed on a stable column so retries are safe. The existing pattern:
PaymentForCreditsis upserted onid = checkout_session_idSubscriptionis upserted on the composite key@@id([subscriptionId, variantId])
When a subscription event arrives, always re-fetch the latest object from Stripe before writing to the DB — the event payload may be stale if events arrive out of order:
const latest = await stripe.subscriptions.retrieve(subscription.id)
Resolving orgId from webhook events
Always write metadata.orgId on both the Checkout Session and its subscription_data.metadata. Webhooks need it on both because checkout.session.completed exposes session metadata, while customer.subscription.* exposes subscription metadata.
Fallback chain (three layers, from most reliable to least):
metadata.orgIdon the event object (checkout session or subscription)metadata.orgIdon the Stripe customer —getOrCreateStripeCustomerwritesorgIdinto the customer's metadata at creation time, so every customer we created has it. This survives even if session/subscription metadata is lost.- Email lookup: find the user by
customer_details.emailand return their first org
Never rely only on email — it's the last resort because users can change email in Stripe Checkout and break the mapping. The customer metadata (step 2) is the strongest fallback because getOrCreateStripeCustomer is the single place that creates customers and it always sets metadata: { orgId }.
Mounting the webhook
Register the webhook sub-app in the parent spiceflow router via .use(). Only the webhook needs a route; checkout and portal flows use server actions.
// src/main.tsx
import { webhookApp } from 'src/lib/stripe-webhook'
export const app = new Spiceflow()
// ... pages, layouts, other sub-apps ...
.use(webhookApp) // ← /api/stripe/webhook
The webhook is publicly reachable — it does not need session auth. Stripe authenticates via the stripe-signature header and constructEvent. If the parent router has an auth middleware, make sure the webhook handler is not blocked by it.
Preventing double subscriptions
Two layers of defense, both required:
Layer 1 — DB check before checkout. Inside the startCheckout server action, query db.query.subscriptions for an active row for this orgId. If one exists, redirect to the portal instead of creating a new Checkout Session. This is the primary guard.
const existing = await db.query.subscriptions.findFirst({
where: {
orgId,
status: { in: ['active', 'trialing', 'past_due'] },
},
})
if (existing) {
const portal = await stripe.billingPortal.sessions.create({ ... })
throw redirect(portal.url)
}
Layer 2 — Single Stripe customer per org. Because Org.stripeCustomerId is reused across every checkout, Stripe itself won't let the same customer subscribe twice to the same recurring price. Even if the DB check somehow races, Stripe returns an error and we never end up with two parallel subs for the same plan.
Do not rely on customer_email to deduplicate — Stripe will happily create a second customer with the same email if you don't pass an explicit customer id.
Preventing double customers
Same pattern: getOrCreateStripeCustomer({ orgId }) should be the only function that calls stripe.customers.create. Grep for it as a code review check:
rg "stripe\.customers\.create" website/src
# Should match ONLY getOrCreateStripeCustomer in website/src/lib/stripe.ts
If you find a second call site, delete it and route through getOrCreateStripeCustomer instead.
The Org.stripeCustomerId column in db/src/schema.ts is the single source of truth. It is set exactly once per org, the first time that org interacts with Stripe, and never updated afterward except on acquisition-style migrations.
Portal configuration
The Billing Portal must be configured once per Stripe account so customers can cancel, switch plans, and update payment methods. Do this via the CLI, not via the dashboard, so the config is reproducible.
Step 1 — collect the product and price ids
All the $PRODUCT_ID, $PRO_MONTHLY, $PRO_YEARLY variables below are placeholder shell variables used for illustration only. They are not stored anywhere. You set them manually in your current shell session from the ids Stripe returned when you created the product and prices earlier.
# Replace each value with the real id from the product/price creation steps above
export PRODUCT_ID=prod_yourProductIdHere
export PRO_MONTHLY=price_yourMonthlyIdHere
export PRO_YEARLY=price_yourYearlyIdHere
If you don't have the ids anymore, look them up with:
stripe prices list --lookup-keys pro_monthly --lookup-keys pro_yearly
Step 2 — create the portal configuration
Run this as a single atomic call — unlike the product/price flow, a portal configuration is created in one Stripe API call, so there is no partial-state risk within the command. But do not chain it after the price creation commands in a script; the shell variables above must already be set and verified before you run it.
stripe billing_portal configurations create \
-d "business_profile[headline]=Manage your subscription" \
-d "features[customer_update][enabled]=true" \
-d "features[customer_update][allowed_updates][0]=email" \
-d "features[customer_update][allowed_updates][1]=address" \
-d "features[customer_update][allowed_updates][2]=tax_id" \
-d "features[invoice_history][enabled]=true" \
-d "features[payment_method_update][enabled]=true" \
-d "features[subscription_cancel][enabled]=true" \
-d "features[subscription_cancel][mode]=at_period_end" \
-d "features[subscription_cancel][cancellation_reason][enabled]=true" \
-d "features[subscription_cancel][cancellation_reason][options][0]=too_expensive" \
-d "features[subscription_cancel][cancellation_reason][options][1]=missing_features" \
-d "features[subscription_cancel][cancellation_reason][options][2]=switched_service" \
-d "features[subscription_cancel][cancellation_reason][options][3]=unused" \
-d "features[subscription_cancel][cancellation_reason][options][4]=other" \
-d "features[subscription_update][enabled]=true" \
-d "features[subscription_update][default_allowed_updates][0]=price" \
-d "features[subscription_update][default_allowed_updates][1]=promotion_code" \
-d "features[subscription_update][proration_behavior]=create_prorations" \
-d "features[subscription_update][products][0][product]=$PRODUCT_ID" \
-d "features[subscription_update][products][0][prices][0]=$PRO_MONTHLY" \
-d "features[subscription_update][products][0][prices][1]=$PRO_YEARLY" \
-d "features[subscription_update][schedule_at_period_end][conditions][0][type]=shortening_interval" \
-d "features[subscription_update][schedule_at_period_end][conditions][1][type]=decreasing_item_amount"
Listing both price ids under the same product is what enables portal-driven monthly↔yearly switching. The schedule_at_period_end conditions make sure downgrades (yearly → monthly, or higher → lower tier) wait until the end of the billing period instead of refunding early.
If the command fails because any of the $PRODUCT_ID, $PRO_MONTHLY, or $PRO_YEARLY shell variables is empty or points to a non-existent id, Stripe returns a clean error and creates nothing — safe to fix and retry.
Adding more tiers: append a second product under
products[1][product]=$TEAM_PRODUCTwith its ownprices[0]=$TEAM_MONTHLY/prices[1]=$TEAM_YEARLY. The portal will let customers switch between any of the 4 prices (pro monthly, pro yearly, team monthly, team yearly).
DB reference
From db/src/schema.ts (drizzle). Table names use snake_case, accessed via schema.orgs, schema.subscriptions:
// db/src/schema.ts
import * as s from 'drizzle-orm/pg-core'
import { defineRelations } from 'drizzle-orm'
export const orgs = s.pgTable('orgs', {
orgId: s.text('org_id').primaryKey().notNull(),
stripeCustomerId: s.text('stripe_customer_id'), // ← single source of truth for the customer
name: s.text('name'),
// ...
})
export const subscriptions = s.pgTable('subscriptions', {
subscriptionId: s.text('subscription_id').notNull(),
variantId: s.text('variant_id').notNull(), // ← Stripe price id (historical name from LemonSqueezy)
productId: s.text('product_id').notNull(), // ← Stripe product id
customerId: s.text('customer_id'), // ← Stripe customer id, denormalized from Org
orgId: s.text('org_id').notNull().references(() => orgs.orgId),
status: s.text('status').notNull(), // active, trialing, canceled, past_due, ...
provider: s.text('provider').notNull(), // stripe | lemonsqueezy (legacy)
metadata: s.jsonb('metadata'),
email: s.text('email'),
orderId: s.text('order_id'),
variantName: s.text('variant_name'),
createdAt: s.timestamp('created_at').defaultNow(),
}, (table) => [
s.primaryKey({ columns: [table.subscriptionId, table.variantId] }), // idempotent upsert key for webhooks
s.index('subscriptions_org_id_idx').on(table.orgId),
])
export const relations = defineRelations({ orgs, subscriptions }, (r) => ({
orgs: {
subscriptions: r.many.subscriptions(),
},
subscriptions: {
org: r.one.orgs({
from: r.subscriptions.orgId,
to: r.orgs.orgId,
}),
},
}))
The composite primary key (subscriptionId, variantId) lets a single subscription carry multiple line items (e.g. base plan + add-on) without the upsert colliding. Use onConflictDoUpdate({ target: [schema.subscriptions.subscriptionId, schema.subscriptions.variantId], set: record }) for idempotent webhook writes.
Common gotchas
- Portal can't switch currency. Once a sub is USD, it stays USD. If a user wants EUR they have to cancel and re-subscribe. Don't try to build a "change currency" button — Stripe won't let you.
tax_behaviormust match across prices of the same product. Set it toexclusiveon every price in the CLI commands above. If one price isunspecifiedand another isexclusive, the portal refuses to let users switch between them.customer_emailvscustomer. Never passcustomer_emailif you have acustomerid. Passing both makes Stripe ignorecustomer_email, which creates confusing UX bugs.- Stripe CLI is a live API. Everything in this skill runs against the active account in
~/.config/stripe/config.toml. Test against a sandbox before running price-creation commands against production (see CLI auth). - Webhooks must return 2xx quickly (< 5s). If you need to run heavy work, enqueue it to
waitUntil - Webhook raw body: call
await request.text()exactly once in the webhook handler. Callingrequest.json()first (or letting a Zodbodyschema parse the request) consumes the stream and breaks signature verification. Do not add abody:schema on the webhook route. Subscription.variantIdis the Stripe price id. The name is historical from the LemonSqueezy days. Do not confuse it with the lookup key.- Errors as values in helpers. Stripe/Drizzle helper functions (like
getOrCreateStripeCustomer) should returnError | Tvia.catch((e) => new TaggedError({ cause: e })). Server actions can then checkinstanceof Errorandthrowto propagate to theErrorBoundary. See the errore skill for the full pattern. currency_optionsis NOT returned by default.stripe prices list/stripe prices retrieveomitcurrency_optionsunless you pass--expand currency_options(or--expand data.currency_optionsfor list). Verifying EUR pricing without the expand shows nothing and looks like the EUR amount failed to save when it actually did. Always verify multi-currency with the expand:stripe prices retrieve $PRICE_ID --expand currency_options stripe prices list --lookup-keys pro_monthly --expand "data.currency_options"tax_behavioron EUR lives undercurrency_options, not the top level. When EUR is added viacurrency_options[eur][unit_amount], the top-leveltax_behavior=exclusiveonly applies to the base USD price. The EUR currency option defaults totax_behavior: unspecifiedunless you also passcurrency_options[eur][tax_behavior]=exclusive. A mismatch (USD exclusive, EUR unspecified) can block portal plan-switching, so set it on both:-d "currency_options[eur][unit_amount]=9900" \ -d "currency_options[eur][tax_behavior]=exclusive"is_defaulton a portal config cannot be set via the API/CLI. Theis_defaultfield is read-only — you cannot pass it tobilling_portal configurations create/update. Only the Stripe Dashboard (Settings → Billing → Customer portal) controls which config is default, andbillingPortal.sessions.createuses that default unless you pass an explicitconfigurationid. So: either set the default in the Dashboard, or pin thebpc_...id in the session-create call. Creating a new config via CLI does NOT make it the default.- The dashboard-managed default portal config drops API-set
subscription_update.products. Updating the default config (the one withis_default: true) via the API to add aproductsarray silently does not persist — the field comes back empty/absent. Configure the portal's switchable product/price list in the Dashboard for the default config, or create + pin a non-default config that does persistproducts.