Stripe
SkillCommerce & financeStripe 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).
Instructions available. Your AI can read the instructions. Execution depends on the setup they require.
Account requirements not reviewed. Check the skill instructions before use; ahel provides instructions and does not run this skill.
Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.
Then ask your AI: use the Stripe skill
What this skill tells your AI
The instructions your AI receives, as published by remorses/opencode-config in skills/stripe/SKILL.md and read by ahel’s review.
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 via sigillo (no global login)
Never use stripe login for global auth. Global CLI auth in ~/.config/stripe/config.toml is dangerous: it silently targets whichever account was logged in last, which can be a completely different project. Instead, always run the Stripe CLI through sigillo so the correct API key is injected per-project.
The Stripe CLI checks the STRIPE_API_KEY environment variable automatically. If set, it overrides everything in config.toml. This is the mechanism we use with sigillo.
Setup
Store the Stripe secret key as STRIPE_API_KEY in sigillo. This single env var is used by both your app code and the Stripe CLI (which picks it up automatically).
sigillo secrets set STRIPE_API_KEY sk_test_... -c dev
sigillo secrets set STRIPE_API_KEY sk_live_... -c prod
If a project already uses STRIPE_SECRET_KEY instead of STRIPE_API_KEY, use --command with shell variable expansion to alias it at runtime:
sigillo run -c prod --command 'STRIPE_API_KEY=$STRIPE_SECRET_KEY stripe products list'
Or copy the value permanently so STRIPE_API_KEY is always available:
sigillo secrets get STRIPE_SECRET_KEY --raw -c dev | sigillo secrets set STRIPE_API_KEY -c dev
sigillo secrets get STRIPE_SECRET_KEY --raw -c prod | sigillo secrets set STRIPE_API_KEY -c prod
Running Stripe CLI commands
Wrap every stripe command with sigillo run so STRIPE_API_KEY is injected:
# simple commands, no shell expansion needed
sigillo run -- stripe products list
sigillo run -- stripe prices list --lookup-keys pro_monthly
# commands that need shell features (&&, pipes, $VARIABLES)
sigillo run --command 'stripe products create --name="Pro" --description="Pro plan"'
# when STRIPE_API_KEY is not set but STRIPE_SECRET_KEY is
sigillo run -c prod --command 'STRIPE_API_KEY=$STRIPE_SECRET_KEY stripe products list'
# webhook listener for local dev
sigillo run -- stripe listen --forward-to http://localhost:8866/api/stripe/webhook
Since STRIPE_API_KEY is in the environment, the CLI picks it up automatically. No --api-key flag needed on each command.
Per-environment targeting
Use sigillo's -c flag to target a specific environment's Stripe account:
# list products on production Stripe account
sigillo run -c prod -- stripe products list
# create a webhook endpoint on preview
sigillo run -c preview -- stripe webhook_endpoints create \
--url="https://preview.your-site.example/api/stripe/webhook" \
-d "enabled_events[]=customer.subscription.created"
Why not --project-name?
The --project-name flag creates separate sections in config.toml, but still relies on global state files and stripe login. With sigillo, the API key is scoped to the project directory and environment. No global config to get stale or point at the wrong account. Multiple projects with different Stripe accounts just work because each has its own sigillo secrets.
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_API_KEY=sk_test_... # server only, via process.env. Also used by Stripe CLI
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_API_KEY: process.env.STRIPE_API_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_API_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_API_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_API_KEY | Copy sk_test_/sk_live_ from https://dashboard.stripe.com/apikeys | Server only. Store in sigillo, never in the repo. Also used by Stripe CLI automatically |
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
sigillo run -- stripe listen --forward-to http://localhost:8040/api/stripe/webhooks
Copy the whsec_... it prints and set STRIPE_WEBHOOK_SECRET in sigillo (sigillo secrets set STRIPE_WEBHOOK_SECRET <value> -c dev). The secret is stable across restarts for the same machine.
Creating products and prices via CLI
All stripe CLI commands in this section must be wrapped with sigillo run (see CLI auth via sigillo). Commands shown as bare stripe ... below are shorthand; always prefix with sigillo run -- or use sigillo run --command '...' when shell variable expansion is needed.
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',
}) {}
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 43
- Forks
- 2
- Last commit
- Sep 2026
ahel review
S4info
community integration, published by remorses, not stripe
Automated review, not a security audit. Ruleset v1+k2.
Advanced
- Item type
- skill
- Key
stripe-remorses- Source
- github.com/remorses/opencode-config
github.com/remorses/opencode-config
Related picks
Skill · thedaviddias
The pick for JavaScriptmodern-javascript-patterns
Skill · wshobson
The pick for JavaScriptsetup-ts-deep-modules
Skill · mattpocock
The pick for TypeScripttypescript-pro
Skill · jeffallan
The pick for TypeScriptreact-component-performance
Skill · davila7
The pick for Reactreact-doctor
Skill · millionco
The pick for React