Stacks Payments
Full Stripe integration via the Payment facade. Uses Stripe API version 2026-01-28.clover. The Stripe SDK is initialized from services.stripe.secretKey (sourced from config/payment.ts).
Key Paths
- Core package:
storage/framework/core/payments/src/ - Payment facade:
storage/framework/core/payments/src/payment.ts - Stripe driver:
storage/framework/core/payments/src/drivers/stripe.ts - Billable modules:
storage/framework/core/payments/src/billable/ - Configuration:
config/payment.ts - SaaS config:
config/saas.ts - Default billing functions:
storage/framework/defaults/functions/billing/payments.ts
Package Exports
Payment(default) -- facade object with all payment methods- All individual functions (
charge,subscribe,checkout, etc.) - All billable modules (
manageCharge,manageCustomer,manageSubscription, etc.) stripe-- raw Stripe SDK instanceStripe-- re-exported Stripe types namespacestacksIdempotencyKey,freshIdempotencyKey-- see Idempotency below
Payment Facade
The Payment object aggregates all payment operations. Every method is also available as a standalone export.
Charges
import { Payment } from '@stacksjs/payments'
// Create and confirm a charge (creates PaymentIntent with confirm: true)
const intent = await Payment.charge(user, 2999, 'pm_xxx', { currency: 'usd' })
// Create PaymentIntent without confirming (for client-side confirmation)
const intent = await Payment.createPayment(user, 2999, { currency: 'usd' })
// Refund -- partial
const refund = await Payment.refund('pi_xxx', 1000)
// Refund -- full (omit amount)
const refund = await Payment.refund('pi_xxx')
charge() sets confirmation_method: 'automatic', confirm: true, attaches the payment method, and delegates to createPayment(). If the user has a stripe_id, it is set as the customer on the PaymentIntent. Default currency is 'usd'.
The manageCharge module also exposes findPayment(id) which retrieves a PaymentIntent by ID, returning null on failure.
Checkout Sessions
// One-time payment checkout
const session = await Payment.checkout(user, [
{ price: 'price_xxx', quantity: 1 }
], { success_url: '/success', cancel_url: '/cancel' })
// Subscription checkout
const subSession = await Payment.subscriptionCheckout(user, 'price_xxx', {
success_url: '/success', cancel_url: '/cancel'
})
Both methods require the user to have a stripe_id (throws if missing). checkout() sets mode: 'payment', subscriptionCheckout() sets mode: 'subscription'.
Subscriptions
// Create -- uses lookup_key to resolve the Stripe Price
const sub = await Payment.subscribe(user, 'premium-monthly')
// Cancel at period end (prorate: true)
const cancelled = await Payment.cancelSubscription('sub_xxx')
// Cancel immediately (invoice_now: true, prorate: false)
const cancelled = await Payment.cancelSubscription('sub_xxx', true)
// Check active subscription
const hasActive = await Payment.hasActiveSubscription(user, 'default')
// Change plan -- swaps the subscription item's price
const changed = await Payment.changeSubscription(user, 'enterprise-monthly')
subscribe() calls managePrice.retrieveByLookupKey(lookupKey) to find the price, then creates the subscription with payment_behavior: 'allow_incomplete' and expand: ['latest_invoice.payment_intent']. It stores the subscription in the subscriptions database table.
isValid() returns true if the subscription status is 'active' or 'trialing'. isIncomplete() checks for 'incomplete' status.
cancel() calls stripe.subscriptions.cancel() and updates provider_status to 'canceled' in the database.
update() retrieves the active subscription via user.activeSubscription(), finds the new price by lookup key, updates the subscription item, and stores the updated price in the database.
Customers
// Get existing or create new Stripe customer
const customer = await Payment.getOrCreateCustomer(user, { name: 'John' })
// Update customer details in Stripe
const updated = await Payment.updateCustomer(user, { name: 'Jane' })
// Delete from Stripe and clear stripe_id on user model
const deleted = await Payment.deleteCustomer(user)
createOrGetStripeUser() checks user.stripe_id first. If the user has one, it retrieves the customer from Stripe. If the customer was deleted (404 or deleted: true), it creates a new one. On creation, it auto-fills name and email from the user model and calls user.update({ stripe_id: customer.id }).
Additional methods on manageCustomer:
stripeId(user)-- returnsuser.stripe_idhasStripeId(user)-- boolean checkcreateStripeCustomer(user, options)-- throws if user already has a stripe_idcreateOrUpdateStripeUser(user, options)-- creates or updatesretrieveStripeUser(user)-- returns customer or undefinedsyncStripeCustomerDetails(user, options)-- updates Stripe with user's name, email, address, locales, metadata
Payment Methods
// Add a payment method to the customer
const pm = await Payment.addPaymentMethod(user, 'pm_xxx')
// Set as default (by Stripe payment method ID string)
const customer = await Payment.setDefaultPaymentMethod(user, 'pm_xxx')
// Remove a payment method (by database record ID number)
const removed = await Payment.removePaymentMethod(user, paymentMethodDbId)
// Create a setup intent for collecting payment methods
const intent = await Payment.createSetupIntent(user, { payment_method_types: ['card'] })
addPaymentMethod() accepts a string (Stripe PM ID) or Stripe.PaymentMethod object. It attaches the PM to the customer if not already attached, then stores it in the payment_methods table with type, last_four, brand, exp_year, exp_month, user_id, provider_id.
setUserDefaultPayment() accepts a Stripe PM ID string, clears existing is_default flags, sets the new default in the database, and updates invoice_settings.default_payment_method on the Stripe customer.
setDefaultPaymentMethod() accepts a database record ID number.
Additional methods on managePaymentMethod:
updatePaymentMethod(user, pmId, params)-- updates PM in StripelistPaymentMethods(user)-- queriespayment_methodstable byuser_idretrievePaymentMethod(user, pmId)-- by database IDretrieveDefaultPaymentMethod(user)-- finds whereis_default: true
Invoices
const invoices = await Payment.getInvoices(user) // lists with expanded payment_intent.payment_method
const invoice = await Payment.createInvoice('cus_xxx', { description: 'Custom invoice' })
const paid = await Payment.payInvoice('inv_xxx')
getInvoices() requires the user to have a stripe_id and expands data.payment_intent.payment_method.
Products & Prices
// Create a product with its price
const { product, price } = await Payment.createProduct('Pro Plan', 2999, {
currency: 'usd', interval: 'month', description: 'Pro features'
})
// Get a price by Stripe lookup_key
const price = await Payment.getPrice('premium-monthly')
// List active products
const products = await Payment.listProducts({ limit: 10 })
createProduct() creates the Stripe product, then creates a price linked to it. If interval is provided, the price gets recurring: { interval }.
Additional methods on manageProduct:
create(params),retrieve(productId),update(productId, params)archive(productId)-- setsactive: falsesearch(query, params)-- Stripe product searchlist(params)-- defaults toactive: true
Additional methods on managePriceExtended:
create(params),retrieve(priceId),update(priceId, params)list(params)-- defaults toactive: truelistByProduct(productId, params)-- prices for a specific productsearch(query, params)-- Stripe price searcharchive(priceId)-- setsactive: false
Coupons & Promo Codes
const coupon = await Payment.createCoupon({
percentOff: 20, duration: 'once', name: 'Holiday Sale', maxRedemptions: 100
})
// Or amount-based: { amountOff: 500, currency: 'usd', duration: 'forever' }
const promo = await Payment.createPromoCode('coupon_xxx', 'SAVE20')
const valid = await Payment.validatePromoCode('SAVE20') // returns PromotionCode | null
createCoupon() supports percentOff, amountOff, currency, duration ('forever' | 'once' | 'repeating'), durationInMonths, name, maxRedemptions.
Additional methods on manageCoupon:
retrieve(couponId),update(couponId, params),delete(couponId),list(params)createPromotionCode(params)-- wrapsstripe.promotionCodes.create()retrievePromotionCode(code)-- lists active promotion codes matching the code string
Transactions
import { manageTransaction } from '@stacksjs/payments'
const tx = await manageTransaction.store(user, productId, {
brand: 'visa', provider_id: 'pi_xxx', description: 'Purchase', type: 'one-time'
})
const txList = await manageTransaction.list(user)
Transactions are stored in the payment_transactions table with name (from product), amount (from product unit_price), brand, type, provider_id, user_id.
Webhooks
import { Payment } from '@stacksjs/payments'
// Register handlers
Payment.onPaymentIntent({
succeeded: async (event) => { /* ... */ },
failed: async (event) => { /* ... */ },
created: async (event) => { /* ... */ },
canceled: async (event) => { /* ... */ },
})
Payment.onSubscription({
created: async (event) => { /* ... */ },
updated: async (event) => { /* ... */ },
deleted: async (event) => { /* ... */ },
trialWillEnd: async (event) => { /* ... */ },
})
Payment.onInvoice({
paid: async (event) => { /* ... */ },
paymentFailed: async (event) => { /* ... */ },
created: async (event) => { /* ... */ },
finalized: async (event) => { /* ... */ },
})
Payment.onCheckout({
completed: async (event) => { /* ... */ },
expired: async (event) => { /* ... */ },
})
Payment.onCharge({
succeeded: async (event) => { /* ... */ },
failed: async (event) => { /* ... */ },
refunded: async (event) => { /* ... */ },
disputed: async (event) => { /* ... */ },
})
// Process incoming webhook request
const result = await Payment.processWebhook(rawPayload, signatureHeader, {
secret: 'whsec_xxx', tolerance: 300
})
// Returns: { success: boolean, eventType?: string, error?: string }
// Low-level: register any event type
Payment.webhook.onWebhookEvent('payment_method.attached', handler)
Payment.webhook.registerWebhookHandlers({ 'payment_intent.succeeded': handler })
// Event data extractors
import { getPaymentIntent, getSubscription, getInvoice, getCheckoutSession, getCharge, getCustomer } from '@stacksjs/payments'
Supported webhook event types: payment_intent.succeeded, payment_intent.payment_failed, payment_intent.created, payment_intent.canceled, customer.subscription.created/updated/deleted, customer.subscription.trial_will_end, customer.created/updated/deleted, invoice.paid/payment_failed/finalized/created, checkout.session.completed/expired, charge.succeeded/failed/refunded, charge.dispute.created/closed, payment_method.attached/detached, setup_intent.succeeded/setup_failed, plus any string.
Setup Products from SaaS Config
Prefer the command; it is what an app author can discover:
buddy stripe:setup --dry-run # report the plan of record, write nothing
buddy stripe:setup # apply it
import { createStripeProduct, formatSetupReport } from '@stacksjs/payments'
const result = await createStripeProduct({ dryRun: true })
if (!result.isErr)
console.log(formatSetupReport(result.value).join('\n'))
This iterates saas.plans and reconciles each one against the live account: a
product is matched by name and reused, and each pricing option is matched by its
lookup_key (from the key field). Prices are immutable in Stripe, so a changed
amount is applied by creating a new price with transfer_lookup_key: true, which
moves the key atomically and leaves the superseded price active so existing
subscriptions keep billing.
Re-running is safe and converges. It does not use products.search on purpose:
that index is eventually consistent, so a second run inside the lag window would
find nothing and create a duplicate.
Utility Functions
Payment.formatAmount(2999, 'usd') // '$29.99' (uses Intl.NumberFormat)
Payment.toCents(29.99) // 2999
Payment.toDollars(2999) // 29.99
Client-Side Billing (useBillable)
The storage/framework/defaults/functions/billing/payments.ts file provides a useBillable() composable for frontend billing UI:
const { loadCardForm, loadPaymentForm, handleAddPaymentMethod, handlePayment } = useBillable()
await loadCardForm(clientSecret) // loads Stripe Card Element
await loadPaymentForm(clientSecret) // loads Stripe Payment Element
await handleAddPaymentMethod(clientSecret, elements) // confirms card setup
await handlePayment(elements) // confirms payment
useBillable() explicitly imports usePaymentStore from the default payment
store. Keep that dependency explicit in imported billing modules. Browser
auto-imports are injected into STX script entries and do not become lexical
globals inside the TypeScript modules those entries bundle.
usePaymentStore() is a callable wrapper around one STX defineStore()
singleton. Its requests resolve the configured API origin, include the current
bearer token, add the CSRF header for writes, and derive mutation route IDs from
the authenticated user. Never restore a fixed localhost port or a hard-coded
user ID.
Dashboard Billing
/settings/billing is a thin route that renders BillingSettings. The
component calls GET /api/dashboard/billing, an authenticated aggregate Action
registered in routes/dashboard-api.ts. Do not call the root /payments/*
group from the dashboard: buddy dev --dashboard delegates /api/* to the
Stacks router and intentionally leaves root GET paths to STX page rendering.
The aggregate always returns persisted PaymentTransaction records for the
authenticated user. Subscription and payment-method reads are provider-backed
and may be unavailable when the application User override is not billable.
Render that as an explicit unavailable state, not sample plans or fake cards.
Idempotency
Stripe calls that create or attach a resource are not idempotent by default.
The classic failure: createStripeCustomer(user) succeeds at Stripe, the
follow-up user.update({ stripe_id }) fails, the next request sees no
stripe_id and creates a second Stripe customer with no link to the local user.
Pass an idempotency key on every create, attach or update call. Stripe caches the response under that key for 24 hours, so a retry returns the original object instead of making a new one.
import { stacksIdempotencyKey } from '@stacksjs/payments'
await stripe.customers.create(params, {
idempotencyKey: stacksIdempotencyKey('customer.create', user.id),
})
Keys are built as stacks:<scope>:<parts...>:v1, hashed when they would exceed
Stripe's 255-character limit, and deterministic: the same inputs always produce
the same key, which is the whole point.
stacksIdempotencyKey(scope, ...parts)is the one to reach for.scopeis a stable operation name and never user input;partsscope it within the user's lifetime.freshIdempotencyKey(scope, ...parts)appends randomness, so it does not deduplicate. Use it only when a repeat call is genuinely a new operation (a second, deliberate charge of the same amount), never as a way to get past a cached response.- Bump the
v1suffix inidempotency.tswhen an operation's parameters change in a way that should not collide with a cached response.
config/payment.ts
{
driver: 'stripe',
stripe: {
publishableKey: env.STRIPE_PUBLISHABLE_KEY || '',
secretKey: env.STRIPE_SECRET_KEY || '',
},
} satisfies PaymentConfig
config/saas.ts
Plans use productName, description, metadata, and a pricing array where each entry has key (lookup_key), price (in cents), interval (optional: 'month' | 'year'), currency:
{
plans: [
{
productName: 'Stacks Hobby',
description: 'All the Stacks features.',
pricing: [
{ key: 'stacks_hobby_early_monthly', price: 1900, interval: 'month', currency: 'usd' },
{ key: 'stacks_hobby_yearly', price: 37900, interval: 'year', currency: 'usd' },
],
metadata: { createdBy: 'admin', version: '1.0.0' },
},
// ... more plans
],
webhook: { endpoint: 'your-webhook-endpoint', secret: 'your-webhook-secret' },
currencies: ['usd'],
coupons: [],
products: [
{ name: 'Stacks Hobby', description: '...', images: ['image-url'] },
],
} satisfies SaasConfig
Database Tables Used
subscriptions-- columns:user_id,type,unit_price,provider_id,provider_status,provider_price_id,quantity,trial_ends_at,ends_at,provider_type,last_used_atpayment_methods-- columns:id,type,last_four,brand,exp_year,exp_month,user_id,provider_id,is_defaultpayment_products-- columns:id,name,unit_pricepayment_transactions-- columns:id,name,description,amount,brand,type,provider_id,user_id
User Model Requirements
The UserModel must have:
id,name,email,stripe_idfieldshasStripeId()method -- returns booleanupdate(data)method -- for persistingstripe_idactiveSubscription()method -- for subscription updates
The framework default storage/framework/defaults/app/Models/User.ts sets
billable: false intentionally because not every application uses payments.
Run buddy publish:model User, keep the override at app/Models/User.ts, and
enable its billable trait before calling instance helpers such as
activeSubscription(), paymentMethods(), or createSetupIntent(). A payment
Action must report the missing trait clearly instead of calling an undefined
method.
Gotchas
- Stripe API keys MUST be in
.envasSTRIPE_SECRET_KEYandSTRIPE_PUBLISHABLE_KEY-- never hardcode them in config files - The Stripe SDK is initialized eagerly -- if
STRIPE_SECRET_KEYis missing, the module throws on import - All amounts are in cents -- use
toCents()andtoDollars()for conversion charge()creates AND confirms the PaymentIntent in one stepsubscribe()resolves the price vialookup_key, not a direct Stripe price IDremovePaymentMethod()takes a database record ID (number), not a Stripe PM ID (string)setDefaultPaymentMethodhas two variants: one takes a Stripe PM ID string (setUserDefaultPayment), the other takes a database ID numbergetOrCreateCustomer()handles deleted Stripe customers by recreating them- Subscription status checks query the local database, not Stripe directly
list()on products defaults toactive: trueonly- Webhook handlers are stored in an in-memory Map -- register them on application startup
processWebhook()usesstripe.webhooks.constructEvent()for signature verification