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()
OperationStripeAdyenPaddleLemon Squeezy
customerStripe customer, created on first useShopper reference (user-<id>); Adyen has no customer objectPaddle customer, found or created by emailStore customer, found or created by email
charge (stored method)YesYes, as an unscheduled card-on-file paymentNo: every Paddle payment goes through its checkoutNo: every payment goes through the hosted checkout
createPayment (browser completes)Client secret for Stripe.jsSession for Drop-in; needs returnUrlTransaction for Paddle.jsNo
refundYesPartial by amount, full as a reversal; the outcome arrives by webhookA refund request Paddle reviews; pending until the webhookIssued at once, whole or partial, against the order
checkout (hosted page)Yes, catalog prices or lines priced hereLines priced here only, one return URL, no subscription modeYour payment-link page; one return URL; subscription mode with catalog pricesHosted page selling one variant; a line priced here sells as the custom-price variant
paymentMethods, removePaymentMethodYes, default markedYes, by shopper referenceYes; Paddle keeps no defaultNo: the card lives on the subscription
subscribeYesNo: charge a stored card on your own scheduleNo: a subscription starts at checkoutNo: a subscription starts at checkout
cancelSubscription, subscriptionsYesNoYesYes, at period end only; listed by email
verifyWebhookStripe-SignatureHMAC per notification itemPaddle-SignatureX-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' in config/payment.ts, plus PADDLE_API_KEY, PADDLE_CLIENT_TOKEN (a client-side token, for Paddle.js) and PADDLE_WEBHOOK_SECRET (your notification destination's secret key). PADDLE_ENVIRONMENT is sandbox unless set to live, 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 its successUrl on the transaction, and the page hands it to Paddle.js. The page needs no inline script, so an app Content-Security-Policy that allows self and https://cdn.paddle.com keeps working.
  • Payments: every payment is a transaction the payer completes in Paddle.js. createPayment returns { provider: 'paddle', transactionId, successUrl? } for Paddle.Checkout.open(). Amounts priced in a request are tax-inclusive, so the payer pays exactly that. Lines priced here use PADDLE_TAX_CATEGORY (default standard), which must be enabled on your Paddle account.
  • Subscriptions: they start at checkout, in subscription mode 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 an adjustment.updated webhook settles it as refund.succeeded or refund.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' in config/payment.ts, plus LEMONSQUEEZY_API_KEY, LEMONSQUEEZY_STORE_ID and LEMONSQUEEZY_WEBHOOK_SECRET (the signing secret of your store's webhook). LEMONSQUEEZY_TEST_MODE=true creates checkouts in test mode.
  • Checkout: a checkout sells one variant, so checkout() takes exactly one line. A line naming price is a variant id, and quantity above 1 is sent as that variant's quantity. A line priced here (name and unitAmount) is sold as the variant in LEMONSQUEEZY_VARIANT_ID at a custom price of unitAmount x quantity, in the store's currency; a checkout in another currency is refused. successUrl is the redirect after paying, and allowPromotionCodes shows 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, subscribe and 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 cancelSubscription needs { atPeriodEnd: true }. Until then a cancelled subscription reports active with cancelAtPeriodEnd: true; it is canceled once it expires.
  • Webhooks: X-Signature is 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_refunded states the order's refunded total rather than the one refund, so it arrives as refundedTotal and 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.succeeded marks the payment succeeded and moves a pending order to PROCESSING.
  • payment.failed marks the payment failed, records the provider's reason in failure_reason and cancels a pending order.
  • refund.succeeded adds the refund to refund_amount. A payment refunded in full becomes refunded and its order REFUNDED; a partial refund leaves it partiallyRefunded and 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-neutralReturns
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) })
BuilderFields 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 as application_fee_percent, which Stripe takes to two decimals (float sums such as 0.1 + 0.2 are snapped, finer values throw).
  • feeCents: an exact fee in minor units. It wins over feePercent on a one-off charge and is refused on a subscription.
  • rounding: 'floor' (the default, as applicationFeeFor) 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'
Eventpayment_statusstatus
checkout.session.completedpaid or no_payment_requiredpaid
checkout.session.completedunpaidprocessing: a delayed method such as SEPA Direct Debit
checkout.session.async_payment_succeededpaidpaid
checkout.session.async_payment_failedanyfailed
checkout.session.expiredanyexpired

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.