@molecule/api-payments-stripe
Provider bond · payments · API (Node) · v1.2.6 · Apache-2.0
Stripe payment provider for molecule.dev.
npm install @molecule/api-payments-stripenpm · Source on GitHub · Implements @molecule/api-payments
How it works
@molecule/api-payments-stripe is a provider bond on the API (Node) side: it implements the payments core interface (@molecule/api-payments) with a concrete vendor or library behind it.
Your code calls the core; you wire this provider once at startup. Swapping vendors later is one line in that wiring, not a rewrite.
// npm install @molecule/api-payments-stripe --workspace=api
// Env: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET — server-side only (only
// the pk_… publishable key is client-side).
import { bond } from '@molecule/api-bond'
import { paymentProvider } from '@molecule/api-payments-stripe'
// api/src/bonds/payments-stripe.ts, called from setupBonds() — powers
// hosted Checkout and server-verified subscription entitlement.
bond('payments', 'stripe', paymentProvider)Works with: @molecule/api-bond, @molecule/api-i18n, @molecule/api-secrets
Secrets: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET
Live demos
Running apps built with Molecule that install this package — open the app or study the template.
Reference
Stripe payment provider for molecule.dev.
Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit
src/index.tsJSDoc, not this file.
Quick Start
// npm install @molecule/api-payments-stripe --workspace=api
// Env: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET — server-side only (only
// the pk_… publishable key is client-side).
import { bond } from '@molecule/api-bond'
import { paymentProvider } from '@molecule/api-payments-stripe'
// api/src/bonds/payments-stripe.ts, called from setupBonds() — powers
// hosted Checkout and server-verified subscription entitlement.
bond('payments', 'stripe', paymentProvider)
Type
provider
Installation
npm install @molecule/api-payments-stripe @molecule/api-analytics @molecule/api-bond @molecule/api-i18n @molecule/api-jwt @molecule/api-payments @molecule/api-proxy-agent @molecule/api-secrets stripe
API
Interfaces
ConnectedAccountBusinessProfile
Subset of Stripe business_profile fields commonly set during marketplace
onboarding.
interface ConnectedAccountBusinessProfile {
name?: string
url?: string
productDescription?: string
supportEmail?: string
supportPhone?: string
mcc?: string
}
CreateConnectedAccountParams
Parameters for creating a connected account.
interface CreateConnectedAccountParams {
/** Stripe account type — `standard`, `express`, or `custom`. */
type: ConnectedAccountType
/** Two-letter ISO country code for the account holder (e.g. `US`, `GB`). */
country: string
/** Email address of the account holder. */
email: string
/** Optional business profile fields (display name, website, MCC, etc.). */
businessProfile?: ConnectedAccountBusinessProfile
/** Optional metadata to attach to the connected account. */
metadata?: Record<string, string>
/** Optional idempotency key for safe retries. */
idempotencyKey?: string
}
CreateConnectedAccountResult
Result of creating a connected account.
interface CreateConnectedAccountResult {
/** Stripe connected account ID (`acct_...`). */
id: string
/** Optional onboarding URL (only present when an account link is created in the same flow). */
accountLinkUrl?: string
}
CreateAccountLinkParams
Parameters for creating an account link.
interface CreateAccountLinkParams {
/** Stripe connected account ID (`acct_...`). */
accountId: string
/** URL Stripe redirects the user back to after onboarding completes. */
returnUrl: string
/** URL Stripe redirects the user to if the link expires before completion. */
refreshUrl: string
/** Whether this is a first-time onboarding link or an update link. */
type: AccountLinkType
/** Optional idempotency key for safe retries. */
idempotencyKey?: string
}
CreateAccountLinkResult
Result of creating an account link.
interface CreateAccountLinkResult {
/** Hosted onboarding URL the connected account holder should open. */
url: string
/** Unix timestamp (seconds) when the link expires. */
expiresAt: number
}
CreateTransferParams
Parameters for creating a transfer to a connected account.
interface CreateTransferParams {
/** Amount in the smallest currency unit (e.g. cents for USD). */
amount: number
/** Three-letter ISO currency code, lowercase (e.g. `usd`). */
currency: string
/** Destination connected account ID (`acct_...`). */
destination: string
/** Optional source charge to attach the transfer to (for separate-charges-and-transfers flow). */
sourceTransaction?: string
/** Optional transfer group string (groups related transfers/charges together). */
transferGroup?: string
/** Optional metadata. */
metadata?: Record<string, string>
/** Optional idempotency key for safe retries. */
idempotencyKey?: string
}
CreateTransferResult
Result of creating a transfer.
interface CreateTransferResult {
/** Stripe transfer ID (`tr_...`). */
id: string
/** Amount transferred (smallest currency unit). */
amount: number
/** Three-letter ISO currency code. */
currency: string
/** Destination connected account ID. */
destination: string
/** Transfer group, if set. */
transferGroup?: string
}
CreatePayoutParams
Parameters for creating a payout from a connected account's Stripe balance to its bank account.
interface CreatePayoutParams {
/** Connected account ID to issue the payout from (`acct_...`). */
accountId: string
/** Amount in the smallest currency unit (e.g. cents for USD). */
amount: number
/** Three-letter ISO currency code, lowercase (e.g. `usd`). */
currency: string
/** Optional payout method — `standard` or `instant`. */
method?: 'standard' | 'instant'
/** Optional metadata. */
metadata?: Record<string, string>
/** Optional idempotency key for safe retries. */
idempotencyKey?: string
}
CreatePayoutResult
Result of creating a payout.
interface CreatePayoutResult {
/** Stripe payout ID (`po_...`). */
id: string
/** Amount paid out (smallest currency unit). */
amount: number
/** Three-letter ISO currency code. */
currency: string
/** Payout status (e.g. `pending`, `paid`, `failed`). */
status: string
/** Estimated arrival date (Unix timestamp in seconds). */
arrivalDate: number
}
AccountStatus
Connected-account onboarding / payout eligibility status.
interface AccountStatus {
/** Stripe connected account ID. */
id: string
/** Whether the account can accept charges. */
chargesEnabled: boolean
/** Whether the account can receive payouts. */
payoutsEnabled: boolean
/** Whether there are any currently-due requirements (Stripe is blocked on something). */
requirementsCurrent: boolean
/** Stripe connected-account type (`standard`, `express`, `custom`), if known. */
type?: ConnectedAccountType
/** Currently-due requirement IDs from Stripe (empty when nothing is due). */
currentlyDue: readonly string[]
}
ConnectWebhookEvent
Normalized Connect webhook event.
Provider-agnostic shape so consumers don't have to import Stripe types to dispatch on event kind.
interface ConnectWebhookEvent {
/** Recognized Connect event type, or `unknown` for unrelated events. */
type: ConnectWebhookEventType
/** Original Stripe event type string (e.g. `account.updated`). */
rawType: string
/** The ID of the primary resource this event is about (account, payout, transfer, fee). */
resourceId?: string
/** The connected account this event applies to, if Stripe sent one. */
accountId?: string
/** Raw event-data object (plain JSON shape — never the live Stripe object). */
data: Record<string, unknown>
}
CheckoutSessionResult
Result of creating or retrieving a checkout session.
interface CheckoutSessionResult {
id: string
url: string | null
/** The Stripe Subscription ID created by the checkout session, if available. */
subscription?: string
}
SubscriptionResult
Normalized subscription data from Stripe.
interface SubscriptionResult {
id: string
status: string
/**
* The Stripe Customer ID (`cus_...`) that owns this subscription. Used to bind
* a verified subscription to the calling user so a foreign subscription id
* cannot be claimed (ownership check in `verifyPayment`).
*/
customer?: string
/**
* The subscription's line items.
*
* `quantity` is the UNITS billed on that line — seats, on a per-seat plan.
* Carried because an app that sells seats has no other way to learn how many
* the customer actually paid for: the price id says what a seat costs, never
* how many were bought, so without this the app can only assume one.
*/
items: {
data: Array<{ id: string; quantity?: number; price?: { id?: string; product?: string } }>
}
current_period_start: number
current_period_end: number
cancel_at_period_end: boolean
canceled_at: number | null
}
SubscriptionUpdateParams
Parameters for updating a subscription.
interface SubscriptionUpdateParams {
items?: Array<{
id: string
price: string
/**
* Units to bill — seats, on a per-seat plan. Stripe keeps the existing
* quantity for any field an update omits, so a caller changing plans must
* state it: leaving it off bills a new flat-priced plan at the old plan's
* seat count.
*/
quantity?: number
}>
cancel_at_period_end?: boolean
/**
* How Stripe bills the change. `'always_invoice'` charges (or credits) the prorated difference
* immediately instead of deferring it to the next renewal invoice.
*/
proration_behavior?: 'create_prorations' | 'always_invoice' | 'none'
/**
* What Stripe does when the charge for the change fails. `'error_if_incomplete'` rejects the
* update, so nothing changes on the subscription.
*/
payment_behavior?:
'allow_incomplete' | 'default_incomplete' | 'error_if_incomplete' | 'pending_if_incomplete'
}
WebhookEventResult
Result of verifying a webhook event.
interface WebhookEventResult {
type: string
data: { object: Record<string, unknown> }
}
NormalizedSubscription
Normalized subscription information.
Use this interface to abstract away provider-specific differences.
interface NormalizedSubscription {
/**
* The payment provider.
*/
provider: PaymentProviderName
/**
* The subscription ID from the provider.
*/
subscriptionId: string
/**
* The product/plan ID.
*/
productId: string
/**
* Current subscription status.
*/
status: SubscriptionStatus
/**
* Whether the subscription is currently active.
*/
isActive: boolean
/**
* When the current period started (Unix timestamp in ms).
*/
currentPeriodStart?: number
/**
* When the current period ends (Unix timestamp in ms).
*/
currentPeriodEnd?: number
/**
* Whether the subscription will auto-renew.
*/
willRenew?: boolean
/**
* When the subscription was canceled (if applicable).
*/
canceledAt?: number
/**
* Raw data from the provider.
*/
rawData: unknown
}
NormalizedPurchase
Normalized purchase information (for one-time purchases).
interface NormalizedPurchase {
/**
* The payment provider.
*/
provider: PaymentProviderName
/**
* The purchase/transaction ID.
*/
purchaseId: string
/**
* The product ID.
*/
productId: string
/**
* Whether the purchase is valid.
*/
isValid: boolean
/**
* When the purchase was made (Unix timestamp in ms).
*/
purchaseDate: number
/**
* Raw data from the provider.
*/
rawData: unknown
}
Types
ConnectedAccountType
Connected account type.
Stripe distinguishes three account types with different onboarding / dashboard responsibilities. See https://stripe.com/docs/connect/accounts.
type ConnectedAccountType = 'standard' | 'express' | 'custom'
AccountLinkType
Account-link types — onboarding (first-time) vs update (returning).
type AccountLinkType = 'account_onboarding' | 'account_update'
ConnectWebhookEventType
Connect webhook event types this provider knows how to interpret.
unknown is returned for any other Stripe event so callers can fall
through to the standard subscription webhook handler if needed.
type ConnectWebhookEventType =
'account.updated' | 'payout.created' | 'transfer.created' | 'application_fee.refunded' | 'unknown'
SubscriptionStatus
Subscription status across providers.
type SubscriptionStatus =
'active' | 'canceled' | 'expired' | 'past_due' | 'trialing' | 'paused' | 'pending' | 'unknown'
PaymentProvider
Payment provider bond interface.
Each payment provider implements the methods relevant to its platform. All methods are optional since different platforms use different flows.
type PaymentProvider = PaymentProviderInterface
Functions
createConnectedAccount(params)
Creates a Stripe connected account for a marketplace seller / driver / provider.
function createConnectedAccount(
params: CreateConnectedAccountParams,
): Promise<CreateConnectedAccountResult>
params— Connected-account creation parameters.
Returns: The new account ID.
createAccountLink(params)
Creates a Stripe account link the connected account holder uses to finish onboarding (or to update payout details).
function createAccountLink(params: CreateAccountLinkParams): Promise<CreateAccountLinkResult>
params— Account-link creation parameters.
Returns: The hosted onboarding/update URL and its expiry (Unix timestamp, seconds).
createTransfer(params)
Transfers funds from the platform balance to a connected account.
function createTransfer(params: CreateTransferParams): Promise<CreateTransferResult>
params— Transfer parameters.
Returns: The created transfer.
createPayout(params)
Issues a payout from a connected account's Stripe balance to its bank account.
Uses Stripe's Stripe-Account header to scope the call to the connected account.
function createPayout(params: CreatePayoutParams): Promise<CreatePayoutResult>
params— Payout parameters.
Returns: The created payout.
getAccountStatus(accountId)
Looks up a connected account's onboarding / payout status.
function getAccountStatus(accountId: string): Promise<AccountStatus>
accountId— Stripe connected account ID (acct_...).
Returns: Normalized account status.
processConnectWebhook(headers, body)
Verifies and normalizes a Stripe Connect webhook event.
Reuses the same STRIPE_WEBHOOK_SECRET env var as the standard webhook
pipeline (and the same signature verification logic) — Connect events
arrive on the same webhook endpoint when the platform's webhook is
configured to receive Connect events.
function processConnectWebhook(
headers: Record<string, string | string[] | undefined>,
body: string | Buffer<ArrayBufferLike>,
): ConnectWebhookEvent
headers— Request headers (looks upstripe-signature).body— The raw request body (string or Buffer).
Returns: The verified, normalized Connect webhook event.
getClient()
Returns the lazily-initialized Stripe client. Throws if STRIPE_SECRET_KEY is not set.
function getClient(): Stripe
Returns: The shared Stripe SDK instance.
createCheckoutSession(options)
Creates a Stripe Checkout session for a new subscription.
function createCheckoutSession(options: {
priceId: string
quantity?: number
successUrl: string
cancelUrl: string
customerId?: string
clientReferenceId?: string
metadata?: Record<string, string>
idempotencyKey?: string
}): Promise<CheckoutSessionResult>
options— Checkout configuration.options.priceId— The Stripe Price ID for the subscription line item.options.successUrl— URL to redirect to after successful payment.options.cancelUrl— URL to redirect to if the user cancels.options.customerId— Optional existing Stripe Customer ID.options.clientReferenceId— Your own id for the buyer (the app's user id). Stripe echoes it back asclient_reference_idon the session and on everycheckout.session.*webhook event, which is how the customer (cus_…) created by this checkout gets linked to the account that paid. Always pass it: without it the first purchase has nothing tying the new Stripe customer to a user.options.metadata— Optional key-value metadata to attach to the session AND to the subscription it creates. Both, deliberately: see below.options.idempotencyKey— Optional idempotency key for safe request retries.options.quantity— Units ofpriceIdto bill — seats, on a per-seat plan. Defaults to 1, which is right for every flat-priced plan. Clamped to a whole number >= 1: Stripe rejects 0 and fractions, and a caller that computed a seat count from a bad read must not turn that into a free subscription.
Returns: The checkout session ID and URL.
createPortalSession(options)
Creates a Stripe Billing Portal session so the user can manage their subscription (update payment method, cancel, view invoices) in Stripe's hosted portal.
function createPortalSession(options: {
customerId: string
returnUrl?: string
}): Promise<{ id: string; url: string } | null>
options— Portal configuration.options.customerId— The Stripe Customer ID to open the portal for.options.returnUrl— URL Stripe returns the user to when they exit the portal. Falls back to APP_ORIGIN/ORIGIN when omitted.
Returns: The portal session ID and URL, or null when Stripe rejects the request (e.g. unknown customer).
getCheckoutSession(sessionId)
Retrieves a Stripe Checkout session by ID, including the associated subscription.
function getCheckoutSession(sessionId: string): Promise<CheckoutSessionResult>
sessionId— The Stripe Checkout session ID.
Returns: The session ID, URL, and subscription ID (if a subscription was created).
getSubscription(subscriptionId)
Retrieves a Stripe subscription by ID with expanded item data.
function getSubscription(subscriptionId: string): Promise<SubscriptionResult>
subscriptionId— The Stripe subscription ID.
Returns: The normalized subscription result.
cancelSubscription(subscriptionId)
Immediately cancels a Stripe subscription.
function cancelSubscription(subscriptionId: string): Promise<SubscriptionResult>
subscriptionId— The Stripe subscription ID to cancel.
Returns: The canceled subscription result.
updateSubscription(subscriptionId, params)
Updates a Stripe subscription (e.g. changes plan, sets cancel_at_period_end).
function updateSubscription(
subscriptionId: string,
params: SubscriptionUpdateParams,
): Promise<SubscriptionResult>
subscriptionId— The Stripe subscription ID to update.params— The Stripe subscription update parameters.
Returns: The updated subscription result.
verifyWebhookSignature(payload, signature)
Verifies a Stripe webhook signature and parses the event payload.
Requires STRIPE_WEBHOOK_SECRET env var.
function verifyWebhookSignature(
payload: string | Buffer<ArrayBufferLike>,
signature: string,
): WebhookEventResult
payload— The raw request body (string or Buffer).signature— Thestripe-signatureheader value.
Returns: The verified webhook event with type and data.
createSetupIntent(options)
Creates a Stripe SetupIntent for the saved-card flow.
If customerId is not provided, a new Stripe customer is created and its
ID is returned alongside the SetupIntent so the resource layer can persist
the customer ID for future SetupIntents and detachments.
function createSetupIntent(options: {
customerId?: string
metadata?: Record<string, string>
idempotencyKey?: string
}): Promise<{ id: string; clientSecret: string; customerId: string }>
options— SetupIntent creation options.options.customerId— Optional existing Stripe customer ID (cus_...).options.metadata— Optional metadata to attach to the SetupIntent.options.idempotencyKey— Optional idempotency key for safe retries.
Returns: The SetupIntent ID, client secret, and customer ID.
retrievePaymentMethod(paymentMethodId)
Retrieves a saved Stripe payment method (card) and returns normalized metadata.
function retrievePaymentMethod(
paymentMethodId: string,
): Promise<{ id: string; brand: string; last4: string; expMonth: number; expYear: number } | null>
paymentMethodId— The Stripe payment method ID (pm_...).
Returns: Brand, last4, and expiry, or null if the lookup fails.
detachPaymentMethod(paymentMethodId)
Detaches a saved Stripe payment method from its customer.
function detachPaymentMethod(paymentMethodId: string): Promise<boolean>
paymentMethodId— The Stripe payment method ID (pm_...).
Returns: true if Stripe acknowledged the detach, false otherwise.
reportUsageOverage(options)
Reports a usage-based OVERAGE charge to Stripe as a one-off invoice item against an existing customer (and, when given, attached to the open invoice of a specific subscription so it lands on the next cycle invoice).
This is the supported, type-safe path on the installed Stripe SDK (v22):
the legacy subscriptionItems.createUsageRecord API was removed in favor of
metered-price meter events / invoice items. A positive-amount invoice item
is the simplest cost-plus overage mechanism — Stripe aggregates open invoice
items and bills them at the customer's cycle close, so repeated incremental
calls accrete onto the same upcoming invoice.
IDEMPOTENCY: the caller MUST pass a stable idempotencyKey derived from
(user, period, amount) so a retry or a double-run within the same Stripe
idempotency window (24h) is collapsed to a single invoice item and never
double-charges (broker safety invariant 4).
This function performs NO gating of its own — it charges whatever it is told to. The decision of WHETHER to charge (configured? opted-in? paid? over budget?) lives entirely in the molecule-dev billing module, which is the single inert/opt-in gate (safety invariants 1 + 2).
function reportUsageOverage(options: {
customerId: string
amountCents: number
currency?: string
priceId: string
subscriptionId?: string
description?: string
metadata?: Record<string, string>
idempotencyKey: string
}): Promise<{ id: string; amountCents: number }>
options— Overage reporting options.options.customerId— The Stripe customer to bill (cus_...).options.amountCents— The overage amount in cents (must be> 0).options.currency— ISO currency (defaults tousd).options.priceId— The metered/overage Price id this reports against; recorded in metadata for reconciliation (the invoice item carries an explicitamount, so the price's unit amount is not used here).options.subscriptionId— Optional subscription to attach the item to so it bills on that subscription's cycle invoice.options.description— Human-readable line description.options.metadata— Extra reconciliation metadata (e.g. period).options.idempotencyKey— REQUIRED stable key (see IDEMPOTENCY above).
Returns: The created invoice item id + the amount actually reported.
chargeOffSession(options)
Charges a customer's saved card OFF-SESSION for a fixed amount — the PREPAID top-up primitive (the customer is not present to authenticate).
Distinct from reportUsageOverage in the property that matters:
reportUsageOverage records an amount to be collected LATER on an invoice,
so the money is a receivable until the cycle closes. This CAPTURES the money
now and reports whether it actually settled, which is what makes a prepaid
balance prepaid. Only credit a balance on status: 'succeeded'.
A saved card is required: the charge uses the customer's invoice_settings. default_payment_method, falling back to their most recent attached card.
With off_session: true + confirm: true, Stripe either settles the payment
or fails — it never returns a client-side flow the absent customer could
complete. A card that demands 3DS therefore surfaces as
status: 'requires_action', which callers MUST treat as a failure and
resolve by asking the customer to re-authenticate on-session.
IDEMPOTENCY: the caller MUST pass a stable idempotencyKey. A retry inside
Stripe's 24h idempotency window returns the ORIGINAL PaymentIntent rather
than charging again — the property that makes an auto-refill retry safe.
This function performs NO gating of its own — it charges what it is told to. Whether a top-up is permitted (balance low? refill cap? velocity?) is the consuming application's decision.
function chargeOffSession(options: {
customerId: string
amountCents: number
currency?: string
paymentMethodId?: string
description?: string
metadata?: Record<string, string>
idempotencyKey: string
}): Promise<{
id: string | null
status: 'succeeded' | 'requires_action' | 'failed'
amountCents: number
declineCode?: string
failureMessage?: string
}>
options— Off-session charge options.options.customerId— The Stripe customer to charge (cus_...).options.amountCents— The amount to capture in cents (must be> 0).options.currency— ISO currency (defaults tousd).options.paymentMethodId— Explicit payment method; defaults to the customer's default card, then their most recently attached card.options.description— Human-readable statement/line description.options.metadata— Reconciliation metadata (e.g. userId, period).options.idempotencyKey— REQUIRED stable key (see IDEMPOTENCY above).
Returns: The PaymentIntent id, the normalized outcome, the amount actually captured (0 unless settled), and a decline code/reason when it did not.
normalizeSubscriptionStatus(rawStatus)
Maps a raw Stripe subscription status string (e.g. past_due, incomplete)
to the provider-agnostic SubscriptionStatus.
Shared between normalizeSubscription (verify path) and the webhook adapter so
both paths derive status identically.
function normalizeSubscriptionStatus(rawStatus: string | undefined): SubscriptionStatus
rawStatus— The raw Stripestatusstring, orundefinedif absent.
Returns: The normalized SubscriptionStatus ('unknown' for unrecognized/missing).
normalizeSubscription(subscription)
Normalizes a Stripe-specific SubscriptionResult to the common
NormalizedSubscription interface used across all payment providers.
function normalizeSubscription(subscription: SubscriptionResult): NormalizedSubscription
subscription— The Stripe subscription result to normalize.
Returns: A NormalizedSubscription with provider-agnostic fields.
Constants
paymentProvider
PaymentProvider-compatible adapter for Stripe.
Wraps the Stripe SDK functions into a PaymentProvider-compatible object
for use with the molecule bond system. Supports subscription verification,
webhook handling, plan upgrades/downgrades, and cancellation.
const paymentProvider: PaymentProviderInterface
stripeSecretDefinitions
Secret definitions required by the Stripe payments bond.
const stripeSecretDefinitions: SecretDefinition[]
Core Interface
Implements @molecule/api-payments interface.
Bond Wiring
Setup function to register this provider with the bond system:
import { bond } from '@molecule/api-bond'
import { paymentProvider } from '@molecule/api-payments-stripe'
export function setupPaymentsStripe(): void {
bond('payments', 'stripe', paymentProvider)
}
Injection Notes
Requirements
Peer dependencies:
@molecule/api-bond^1.0.1@molecule/api-i18n^1.0.1@molecule/api-payments^1.1.0@molecule/api-jwt^1.0.1@molecule/api-secrets^1.0.1
Environment Variables
STRIPE_SECRET_KEY(required) — Stripe secret key- Setup: Stripe Dashboard → Developers → API keys; use the sk_test_ key in test mode, sk_live_ in production.
- Get it here: https://dashboard.stripe.com/apikeys
- Example:
sk_test_...
STRIPE_WEBHOOK_SECRET(required) — Stripe webhook signing secret- Setup: Stripe Dashboard → Developers → Webhooks → Add endpoint pointing at {apiUrl}/api/users/payment-notification/stripe, then copy its signing secret.
- Get it here: https://dashboard.stripe.com/webhooks
- Example:
whsec_...
Runtime Dependencies
@molecule/api-analytics@molecule/api-bond@molecule/api-i18n@molecule/api-jwt@molecule/api-payments@molecule/api-proxy-agent@molecule/api-secretsstripe
Bond this as the payments provider so @molecule/api-payments's verifySubscription (and
the payment resource) work server-side — don't call the Stripe SDK directly for
verification. Env: STRIPE_SECRET_KEY + STRIPE_WEBHOOK_SECRET are SERVER-ONLY; only the
publishable key (pk_…) is client-side.
You do NOT need molecule's Express app, the bond() wiring, or the UI packages to use
this — the functions below are framework-agnostic. On a non-Express / non-molecule host
(Next.js App Router, serverless functions, Hono, Fastify), import them and call them from
your OWN route handlers:
import { createCheckoutSession, verifyWebhookSignature, getSubscription } from '@molecule/api-payments-stripe'.
They cover the whole flow — createCheckoutSession (server-owned priceId, so you
never take a price/amount from the client), createPortalSession (hosted Billing
Portal for payment-method updates/cancellation/invoices), verifyWebhookSignature,
and the subscription getters/updaters — and carry the security contract (config-not-configured
errors, normalized status) for free, so reach for these instead of hand-rolling raw
stripe calls. In a Next.js App Router route, read the RAW webhook body with
await req.text() and the header with req.headers.get('stripe-signature'), then
verifyWebhookSignature(rawBody, signature) (the express.raw(...) note below is the
Express-host equivalent). Only @molecule/api-middleware-billing-routes (Express glue) and
@molecule/app-billing-react (molecule UI) are framework-coupled — skip THOSE on such a
host, but still use these bond functions underneath.
Two things a weak Stripe integration gets wrong:
- The webhook body MUST be RAW for signature verification.
verifyWebhookSignature(Stripe'sconstructEvent) hashes the exact bytes, so a parsed-then-re-serialized body ALWAYS fails. In a molecule app you need NO special middleware: the always-included@molecule/api-middleware-body-parser-expressalready captures the unparsed body asreq.rawBody(a string) on EVERY request — just passreq.rawBody+ thestripe-signatureheader toverifyWebhookSignature. Do NOT add a route-specificexpress.raw(...)here — it is redundant and fights the global JSON parser (which has already consumed the stream and setreq.rawBody). (ONLY on a NON-molecule Express host that lacksreq.rawBodydo you mountexpress.raw({ type: 'application/json' })before the JSON parser; on Next.js App Router read the raw body withawait req.text().) NEVER act on an unverified webhook body — it is attacker-controlled. - Webhooks are redelivered — be idempotent. Stripe retries until it gets a 2xx, so the
same
event.idcan arrive twice. Dedupe on it (the payment record'sUNIQUE(platformKey, transactionId)already blocks a double-grant), and return 2xx once handled so Stripe stops retrying.
Create checkout with SERVER-configured price ids (createCheckoutSession) — never an
amount sent by the client.
Checkout returns the buyer to the APP, and the session must name the buyer.
paymentProvider.updateSubscription builds success_url /cancel_url with
resolveCheckoutRedirectUrls from @molecule/api-payments, so Stripe returns to
APP_ORIGIN/plan-updated?provider=stripe&sessionId={CHECKOUT_SESSION_ID} — the page
then calls POST /users/:id/verify-payment/stripe with that id from an origin whose
cookies authenticate. Returning to a separate API host instead sends a top-level
redirect with NO credentials, so the callback answers 401 and the paid plan is never
granted. It also sets client_reference_id + metadata.userId to the app's user id;
they are how a webhook links the newly created cus_… to the account that paid on a
FIRST purchase, and without them nothing ever writes the customer id — so any
usage/metered billing keyed on it can never charge. Pass clientReferenceId when you
call createCheckoutSession yourself.
A missing STRIPE_SECRET_KEY is NOT the same as "no active subscription."
getClient() throws a tagged config-not-configured error; verifySubscription,
updateSubscription, and cancelSubscription on paymentProvider detect
that tag (isConfigNotConfiguredError from @molecule/api-payments) and
RETHROW it instead of swallowing it into the same null / { updated: false }
/ false a genuine verification/update failure returns — so a caller (or its
own catch block) can tell "the operator forgot to set the secret" apart from
"this subscription/card is invalid" and surface the actionable 503 instead of
a generic 400/500.
Runs behind an outbound proxy when HTTPS_PROXY is set. The stripe SDK
builds its own agent and reads no proxy variable, so on a host whose only
egress path is a proxy every call used to fail with a bare connection error.
This bond now passes a CONNECT-capable agent into the SDK's httpAgent hook
(@molecule/api-proxy-agent, resolved against https://api.stripe.com so
NO_PROXY is honoured). With no proxy configured nothing is passed and the
SDK keeps its own default agent — a standalone app behaves exactly as before.
Allowlist api.stripe.com on the proxy.
E2E Tests
Integration checklist — drive the real UI (live preview, no mocks; use the provider's TEST mode — test cards/sandbox accounts, never a live charge), adapt each item to this app's actual screens/flows, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip:
- Starting an upgrade/subscribe from the pricing or billing surface creates a checkout session and hands off to the provider flow (redirect or embedded element) — the button does something real, not a dead click.
- Returning from a canceled/abandoned checkout leaves the user on their original plan with a sane UI (no phantom entitlement, no error page).
- Entitlement flips ONLY after server-side verification (webhook or verify
call) — reloading after a client-side-only "success" must NOT show a paid
plan unless the server verified it. The sandbox CAPTURES webhook deliveries
— read them with the
read_activitytool (filter type 'webhook'); never mock the event or modify production code to fake an entitlement. - The current subscription status (plan name, renewal/expiry) renders on the account/billing screen, and canceling updates that status visibly.
- With payment secrets unconfigured, the flow surfaces an actionable "credentials not configured" message — not a silent no-op or generic 500.
- The provider SECRET key never reaches the browser (page + network traffic contain only the publishable key).
Translations
Translation strings are provided by @molecule/api-locales-payments-stripe.