Payments
On this page 14
@stacksjs/payments provides Stripe customers, payment intents, checkout sessions, subscriptions, payment methods, invoices, products, prices, coupons, transactions, and signed webhooks.
Payment drivers
paymentDriver() returns the provider config.payment.driver names, stripe unless it says otherwise. Every driver speaks the same terms: money as { amount, currency } in minor units, payment statuses of our own (succeeded, processing, requires_action, requires_payment_method, failed, canceled), and webhook events of our own (payment.succeeded, refund.succeeded, checkout.completed, ...).
import { paymentDriver } from '@stacksjs/payments'
const payments = paymentDriver()
const result = await payments.charge(user, { amount: 2999, currency: 'eur' }, storedCardId, {
reference: `order-${order.id}`,
idempotencyKey: `order-${order.id}`,
})
if (result.status === 'requires_action')
return { redirect: result.redirectUrl }
const session = await payments.checkout(user, {
mode: 'payment',
currency: 'eur',
lines: [{ name: 'Annual pass', unitAmount: 9900, quantity: 1 }],
successUrl: 'https://example.com/billing/done',
})
Webhooks are verified and translated in one call; reply with the driver's own acknowledgement:
const events = await payments.verifyWebhook({ payload: await request.text(), headers: request.headers })
for (const event of events) {
if (event.type === 'payment.succeeded')
await markPaid(event.merchantReference, event.amount)
}
return payments.acknowledgeWebhook()
| Operation | Stripe | Adyen | Paddle | Lemon Squeezy |
|---|---|---|---|---|
customer | Stripe customer, created on first use | Shopper reference (user-<id>); Adyen has no customer object | Paddle customer, found or created by email | Store customer, found or created by email |
charge (stored method) | Yes | Yes, as an unscheduled card-on-file payment | No: every Paddle payment goes through its checkout | No: every payment goes through the hosted checkout |
createPayment (browser completes) | Client secret for Stripe.js | Session for Drop-in; needs returnUrl | Transaction for Paddle.js | No |
refund | Yes | Partial by amount, full as a reversal; the outcome arrives by webhook | A refund request Paddle reviews; pending until the webhook | Issued at once, whole or partial, against the order |
checkout (hosted page) | Yes, catalog prices or lines priced here | Lines priced here only, one return URL, no subscription mode | Your payment-link page; one return URL; subscription mode with catalog prices | Hosted page selling one variant; a line priced here sells as the custom-price variant |
paymentMethods, removePaymentMethod | Yes, default marked | Yes, by shopper reference | Yes; Paddle keeps no default | No: the card lives on the subscription |
subscribe | Yes | No: charge a stored card on your own schedule | No: a subscription starts at checkout | No: a subscription starts at checkout |
cancelSubscription, subscriptions | Yes | No | Yes | Yes, at period end only; listed by email |
verifyWebhook | Stripe-Signature | HMAC per notification item | Paddle-Signature | X-Signature |
An operation a driver cannot do throws PaymentUnsupportedError, naming the driver and the operation; a provider's rejection throws PaymentProviderError with its status and code. driver.capabilities lists what a driver supports.
Adyen
Set driver: 'adyen' in config/payment.ts and ADYEN_API_KEY, ADYEN_MERCHANT_ACCOUNT and ADYEN_HMAC_KEY (the hex key of your standard webhook). ADYEN_ENVIRONMENT=live also needs ADYEN_LIVE_URL_PREFIX, your account's endpoint prefix from the Customer Area. The driver uses Checkout API v72.
Paddle
Paddle Billing is a merchant of record: it is the seller, charges and remits the tax, and reviews refunds. The driver says so rather than hiding it.
- Setup: set
driver: 'paddle'inconfig/payment.ts, plusPADDLE_API_KEY,PADDLE_CLIENT_TOKEN(a client-side token, for Paddle.js) andPADDLE_WEBHOOK_SECRET(your notification destination's secret key).PADDLE_ENVIRONMENTissandboxunless set tolive, and the API key must belong to that environment. - Checkout: Paddle has no hosted checkout page. A transaction's payment link is your own page, which loads Paddle.js. Stacks serves that page at
GET /payments/checkout; set it as the default payment link in Paddle (Checkout > Checkout settings).checkout()stores itssuccessUrlon the transaction, and the page hands it to Paddle.js. The page needs no inline script, so an app Content-Security-Policy that allowsselfandhttps://cdn.paddle.comkeeps working. - Payments: every payment is a transaction the payer completes in Paddle.js.
createPaymentreturns{ provider: 'paddle', transactionId, successUrl? }forPaddle.Checkout.open(). Amounts priced in a request are tax-inclusive, so the payer pays exactly that. Lines priced here usePADDLE_TAX_CATEGORY(defaultstandard), which must be enabled on your Paddle account. - Subscriptions: they start at checkout, in
subscriptionmode with catalog prices that carry a billing cycle. A trial belongs to the Paddle price, not the checkout. - Refunds: each refund is a request Paddle reviews. It returns
pending, and anadjustment.updatedwebhook settles it asrefund.succeededorrefund.failed. A partial amount is taken from the transaction's line items in order, tax included. - Webhooks: Paddle wants a 200 within five seconds, and signatures older than five seconds are refused, as Paddle's SDKs do.
- Verification: the driver is checked against Paddle's API reference and its webhook signatures against Paddle's official Node SDK. It has not yet run against a live Paddle account.
Lemon Squeezy
Lemon Squeezy is a merchant of record with a hosted checkout, and its API is narrower than a processor's.
- Setup: set
driver: 'lemonsqueezy'inconfig/payment.ts, plusLEMONSQUEEZY_API_KEY,LEMONSQUEEZY_STORE_IDandLEMONSQUEEZY_WEBHOOK_SECRET(the signing secret of your store's webhook).LEMONSQUEEZY_TEST_MODE=truecreates checkouts in test mode. - Checkout: a checkout sells one variant, so
checkout()takes exactly one line. A line namingpriceis a variant id, andquantityabove 1 is sent as that variant's quantity. A line priced here (nameandunitAmount) is sold as the variant inLEMONSQUEEZY_VARIANT_IDat a custom price ofunitAmount x quantity, in the store's currency; a checkout in another currency is refused.successUrlis the redirect after paying, andallowPromotionCodesshows the discount field. There is no cancel page and no per-checkout trial: a trial is set on the subscription variant. - No server-side payments:
charge,createPayment,subscribeand the payment-method calls are unsupported. Everything starts at the hosted checkout. - Refunds: issued at once against the order, whole or by amount. A full refund comes back with
amount: null, since Lemon Squeezy does not restate it. - Subscriptions: listed by the payer's email in your store. Cancelling always runs to the end of the period paid for, so
cancelSubscriptionneeds{ atPeriodEnd: true }. Until then a cancelled subscription reportsactivewithcancelAtPeriodEnd: true; it iscanceledonce it expires. - Webhooks:
X-Signatureis HMAC-SHA256 hex of the raw body under the signing secret. A delivery carries no event id, so a digest of its bytes is the id, which a retry repeats.order_refundedstates the order's refunded total rather than the one refund, so it arrives asrefundedTotaland commerce records the difference from what it already has. - Verification: checked against Lemon Squeezy's API reference. It has not yet run against a live store.
The Payment facade's Stripe-only functions (below), the raw stripe client and the manage* modules remain Stripe's API.
Billable models
A model with traits: { billable: true } gets the driver's operations as instance methods, so they follow config.payment.driver too:
const customer = await user.paymentCustomer()
const result = await user.charge({ amount: 2999, currency: 'eur' }, storedCardId, { reference: `order-${order.id}` })
const session = await user.checkout({
mode: 'subscription',
lines: [{ price: 'price_pro_monthly', quantity: 1 }],
successUrl: 'https://example.com/billing/done',
cancelUrl: 'https://example.com/billing',
})
const subscription = await user.newSubscription('default', 'pro_monthly')
await user.cancelSubscription(subscription.id, { atPeriodEnd: true })
A subscription checkout can start with a trial: trialDays: 14. A checkout's reference and metadata are copied onto the payment or subscription it creates, so the webhooks about those can be attributed; a session's own metadata never reaches them on Stripe. allowPromotionCodes and automaticTax are Stripe only, and Adyen refuses them.
paymentCustomer, charge, createPayment, checkout, paymentMethods, removePaymentMethod, newSubscription, cancelSubscription and activeSubscription return the driver's own shapes (CheckoutSession, SubscriptionSummary, ...). cancelSubscription refuses a subscription the record does not own. The Stripe-only methods (createStripeUser and the other *StripeUser methods, setDefaultPaymentMethod, addPaymentMethod, updateSubscription, createSetupIntent, subscriptionHistory and the Connect methods) throw PaymentUnsupportedError when another driver is configured.
Orders from payment webhooks
With commerce on, the framework mounts this for you at POST /webhooks/payments (the payments route bundle): point Stripe's endpoint there with STRIPE_WEBHOOK_SECRET set, Adyen's standard webhook with ADYEN_HMAC_KEY, a Paddle notification destination with PADDLE_WEBHOOK_SECRET, or a Lemon Squeezy store webhook with LEMONSQUEEZY_WEBHOOK_SECRET. Until the secret is set it answers 401 and applies nothing; a signature that does not verify gets 400. To mount it yourself, orders.receivePaymentWebhook(request) is the whole handler. Underneath, orders.handleCommercePaymentEvent applies one verified event to the order it concerns, from any driver. A payment's reference is what the order's payments.transaction_id holds.
import { orders } from '@stacksjs/commerce'
import { paymentDriver } from '@stacksjs/payments'
route.post('/webhooks/payments', async (request) => {
const payments = paymentDriver()
const events = await payments.verifyWebhook({ payload: await request.text(), headers: request.headers })
for (const event of events)
await orders.handleCommercePaymentEvent(event)
return payments.acknowledgeWebhook()
})
payment.succeededmarks the payment succeeded and moves a pending order to PROCESSING.payment.failedmarks the payment failed, records the provider's reason infailure_reasonand cancels a pending order.refund.succeededadds the refund torefund_amount. A payment refunded in full becomesrefundedand its order REFUNDED; a partial refund leaves itpartiallyRefundedand the order where it was.
A refund event's amount is that one refund, never a running total. Each (provider, id) is recorded in payment_webhook_events in the same transaction as the update, so a provider's retry does nothing.
Configure Stripe
Set STRIPE_SECRET_KEY and STRIPE_PUBLISHABLE_KEY in the environment. config/payment.ts selects the Stripe driver and reads those values.
Never hardcode API keys or webhook secrets in application source.
The Payment facade
Payment from @stacksjs/payments has two halves.
The provider-neutral half goes through the configured driver, so the same call works on Stripe and Adyen. Money is { amount, currency } in minor units, and results are the driver's own shapes:
import { Payment } from '@stacksjs/payments'
const result = await Payment.charge(user, { amount: 2999, currency: 'usd' }, 'pm_example', {
reference: `order-${order.id}`,
})
const session = await Payment.checkout(user, {
mode: 'payment',
lines: [{ price: 'price_example', quantity: 1 }],
successUrl: 'https://example.com/billing/success',
cancelUrl: 'https://example.com/billing',
})
const upgrade = await Payment.subscriptionCheckout(user, 'price_pro', {
successUrl: 'https://example.com/welcome',
cancelUrl: 'https://example.com/pricing',
})
const subscription = await Payment.subscribe(user, 'premium-monthly')
await Payment.cancelSubscription(subscription.id, { atPeriodEnd: true })
| Provider-neutral | Returns |
|---|---|
charge(payer, money, paymentMethod, options) | PaymentResult |
createPayment(payer, money, options) | PaymentResult, with the browser's clientConfirmation |
refund(paymentId, { amount?, reason? }) | RefundResult |
checkout(payer, request), subscriptionCheckout(payer, price, options) | CheckoutSession |
subscribe(payer, price, { type? }) | SubscriptionSummary |
cancelSubscription(id, { atPeriodEnd? }) | SubscriptionSummary |
subscriptions(payer) | SubscriptionSummary[] |
hasActiveSubscription(user, type?) | boolean, from the local subscriptions table |
getOrCreateCustomer(payer) | Customer |
paymentMethods(payer) | StoredPaymentMethod[] |
removePaymentMethod(payer, providerId) | nothing |
The Stripe-only half keeps Stripe's types and throws PaymentUnsupportedError, naming the operation, under another driver: changeSubscription, updateCustomer, deleteCustomer, addPaymentMethod, setDefaultPaymentMethod, createSetupIntent, the invoice functions, createProduct, getPrice, listProducts, the coupon and promotion-code functions, and billingPortal. Anything Stripe-specific the neutral half cannot say, such as a Connect destination charge or prorate on a cancellation, is the manage* modules' job.
cancelSubscription(id) cancels now; { atPeriodEnd: true } stops renewal and keeps the customer entitled until the period ends, recording that date in the subscription's ends_at. It replaces cancelSubscription(id, immediately), whose false also cancelled at once, only with a proration.
Amounts are integers in the smallest currency unit. Use Payment.toCents(), Payment.toDollars(), and Payment.formatAmount() for conversion and display.
Stripe Connect: destination charges
A marketplace sale is charged on the platform and settled by Stripe to the merchant's connected account, less the platform's application fee. When you build the Stripe call yourself, spread the Connect fields from these builders. They are pure (no config, no API call), validate their input, and are typed with Stripe's own parameter types.
import {
checkoutLineItemsTotal,
destinationCheckoutPaymentData,
destinationCheckoutSubscriptionData,
destinationPaymentIntentParams,
destinationSubscriptionParams,
stripe,
} from '@stacksjs/payments'
const connect = { destination: gym.stripe_account_id, feePercent: 5, rounding: 'round' as const }
// A one-off Checkout: the fee is an amount, worked out from the line items
const session = await stripe.checkout.sessions.create({
mode: 'payment',
customer,
line_items: lineItems,
payment_intent_data: destinationCheckoutPaymentData(checkoutLineItemsTotal(lineItems), connect, { metadata }),
success_url: successUrl,
cancel_url: cancelUrl,
})
// A subscription Checkout: the fee is a percent of every invoice
await stripe.checkout.sessions.create({
mode: 'subscription',
customer,
line_items: lineItems,
subscription_data: destinationCheckoutSubscriptionData(connect, { metadata, trial_period_days: 14 }),
success_url: successUrl,
cancel_url: cancelUrl,
})
// Directly, for a customer with a payment method on file
await stripe.subscriptions.create({ customer, items: [{ price }], ...destinationSubscriptionParams(connect) })
await stripe.paymentIntents.create({ amount: 4900, currency: 'eur', customer, ...destinationPaymentIntentParams(4900, connect) })
| Builder | Fields it returns |
|---|---|
destinationCheckoutPaymentData(amount, options, extra?) | payment_intent_data: transfer_data, application_fee_amount, on_behalf_of, transfer_group |
destinationCheckoutSubscriptionData(options, extra?) | subscription_data: transfer_data, application_fee_percent, on_behalf_of |
destinationSubscriptionParams(options) | transfer_data, application_fee_percent, on_behalf_of |
destinationPaymentIntentParams(amount, options) | transfer_data, application_fee_amount, on_behalf_of, transfer_group |
Options:
destination: the connected account,acct_...; anything else throws.feePercent: 0 to 100. A one-off charge turns it into an amount; a subscription sends it asapplication_fee_percent, which Stripe takes to two decimals (float sums such as0.1 + 0.2are snapped, finer values throw).feeCents: an exact fee in minor units. It wins overfeePercenton a one-off charge and is refused on a subscription.rounding:'floor'(the default, asapplicationFeeFor) or'round'. The fee never exceeds the charge.onBehalfOf: settle as the merchant, with their statement descriptor.transferGroup: one-off charges only.
A zero or missing fee sends no fee field. The Checkout builders' extra
argument carries the session's other fields; the Connect fields win over it.
destinationFeeCents(amount, options) and destinationFeePercent(options) are
the fee arithmetic alone, and checkoutLineItemsTotal(lineItems) totals
price_data lines (a catalog price throws, since its amount is not known here).
Splitting a fee
splitFee(amount, weights, fallback?) divides minor units into named shares
that always add up to the amount: each share gets its whole part and the cents
left over go to the largest fractions, ties to the earlier key.
import { splitFee } from '@stacksjs/payments'
splitFee(101, { tip: 2, planet: 1 }) // { tip: 67, planet: 34 }
splitFee(-101, { tip: 2, planet: 1 }) // { tip: -67, planet: -34 }, a refunded fee
splitFee(50, { tip: 0, planet: 0 }) // { tip: 50, planet: 0 }: all-zero weights go to the fallback (the first key)
Stripe Connect: refunds
Refunded like any other charge, a destination charge is paid back out of the platform's balance, the merchant keeps the sale and the platform keeps its fee. Stripe reverses the transfer and the fee only when the refund asks:
import { connectRefund, connectRefundParams, stripe } from '@stacksjs/payments'
// Looks the payment up, then refunds with reverse_transfer and refund_application_fee as needed
await connectRefund(payment.stripe_payment_intent_id, {
amount: 500,
reason: 'requested_by_customer',
metadata: { refund_id: String(refund.id) },
idempotencyKey: `refund:${refund.id}`,
})
// Or build the params from an object you already hold
const intent = await stripe.paymentIntents.retrieve(id, { expand: ['latest_charge'] })
await stripe.refunds.create(connectRefundParams(intent, { amount: 500 }), { idempotencyKey: `refund:${refund.id}` })
connectRefundParams(source, options) refunds by payment_intent for a
PaymentIntent and by charge otherwise. It sets reverse_transfer when the
money went to a connected account (transfer_data.destination on the intent or
its charge, or a charge's transfer) and refund_application_fee when that
charge also took a fee. reverseTransfer: false and
refundApplicationFee: false opt out. isDestinationCharge(source) and tookApplicationFee(source)
answer the two questions on their own. Pass connectRefund a deterministic
idempotencyKey; without one each call is a new refund. client swaps the
Stripe client it reads and refunds through: a test double, or one scoped to a
connected account.
Checkout outcomes
Four checkout.session.* events and a payment_status combine into four
outcomes. checkoutOutcome(event) reads them, and returns null for any other
event:
import { checkoutOutcome } from '@stacksjs/payments'
const outcome = checkoutOutcome(event)
if (!outcome || outcome.metadata.order_id === undefined)
return
if (outcome.status === 'paid')
await fulfilOrder(Number(outcome.reference), outcome.paymentIntentId)
else if (outcome.status === 'processing')
await markAwaitingDebit(Number(outcome.reference))
else
await cancelOrder(Number(outcome.reference)) // 'failed' or 'expired'
| Event | payment_status | status |
|---|---|---|
checkout.session.completed | paid or no_payment_required | paid |
checkout.session.completed | unpaid | processing: a delayed method such as SEPA Direct Debit |
checkout.session.async_payment_succeeded | paid | paid |
checkout.session.async_payment_failed | any | failed |
checkout.session.expired | any | expired |
The outcome also has session, sessionId, reference (the session's
client_reference_id), paymentIntentId, subscriptionId and customerId
(ids whether Stripe expanded them or not), mode, amountTotal, currency and
metadata. It only reads the event: verify the webhook signature and match the
session to your own record before acting on it.
Process signed webhooks
Payment.onPaymentIntent({
succeeded: async (event) => {
// Apply idempotent domain updates.
},
})
const result = await Payment.processWebhook(rawPayload, signature, {
secret: process.env.STRIPE_WEBHOOK_SECRET,
tolerance: 300,
})
Use the unmodified request body for signature verification. Register webhook handlers during application startup and make every handler idempotent.
Stripe delivers each event at least once, so processWebhook runs an event's handlers once per event id. A replay of an event that already ran answers { success: true, duplicate: true } without calling them. The id is recorded only after every handler succeeded: when one throws, processWebhook answers success: false, nothing is recorded, and Stripe's retry runs the handlers again. Ids are kept in payment_webhook_events (provider stripe:handlers, apart from the rows commerce writes for POST /webhooks/payments), and in memory until that table is migrated. Pass dedupe: false to run handlers on every delivery, or store to keep the ids somewhere else (setWebhookEventStore() changes the default).
tolerance is the clock skew, in seconds, a signature may have. For the driver path (paymentDriver().verifyWebhook), set payment.stripe.webhookTolerance in config/payment.ts; it falls back to payment.webhook.tolerance, then Stripe's 300.
Connected accounts
One user can own several connected accounts, such as a gym and a coach storefront. Name each one with a stable reference:
import { manageConnectedAccount } from '@stacksjs/payments'
const account = await manageConnectedAccount.createConnectedAccount(user, { country: 'DE' }, { reference: `gym:${gym.id}` })
The idempotency key is built from the user, the reference and the creation parameters, so a retry of the same call returns the original account, and another reference or other parameters create another account. Without a reference, two accounts with identical parameters for one user inside Stripe's 24-hour window are treated as one.
The local billing tables track subscriptions, payment methods, products, and transactions. Stripe remains the payment processor and source for provider state.