← All @molecule/* packages · App templates
@molecule/api-paymentsCore interface · payments · API (Node) · v1.2.0 · Apache-2.0
Subscriptions and in-app purchases
npm install @molecule/api-payments@molecule/api-payments is the payments core interface on the API (Node) side: the API your app calls, with no vendor inside.
Choose the implementation by bonding one of its 4 providers: @molecule/api-payments-apple, @molecule/api-payments-google, @molecule/api-payments-paypal, @molecule/api-payments-stripe.
// Server-side handler: verify BEFORE granting. The bonded provider implements
// PaymentProviderInterface; the client sends only an opaque id/receipt, never a
// status or amount. Bonds return null unless the provider confirms a real,
// currently-entitled subscription — so `sub != null` IS the entitlement gate.
import { get } from '@molecule/api-bond'
import type { PaymentProviderInterface } from '@molecule/api-payments'
router.post('/subscriptions/activate', async (req, res) => {
const userId = getUserId(res)
if (!userId) return res.status(401).json({ error: 'Authentication required.' })
// Stripe-style; Apple/Google use payments.verifyReceipt(receipt, productId) /
// payments.verifyPurchase(purchaseToken, productId) with the same null contract.
const payments = get<PaymentProviderInterface>('payments', 'stripe')
const sub = payments?.verifySubscription
? await payments.verifySubscription(req.body.subscriptionId) // opaque `cs_…`/`sub_…` id
: null
if (!sub) {
return res.status(402).json({ error: 'No active subscription.' }) // trust the verify, not the client
}
if (sub.transactionId && (await paymentExists(sub.transactionId))) {
return res.status(409).json({ error: 'This subscription is already linked.' }) // replay guard
}
await grantEntitlement(userId, sub) // store the VERIFIED result server-side
res.json({ expiresAt: sub.expiresAt, autoRenews: sub.autoRenews })
})Providers (4): @molecule/api-payments-apple, @molecule/api-payments-google, @molecule/api-payments-paypal, @molecule/api-payments-stripe
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.
Payments core interface for molecule.dev.
Defines common types and interfaces for payment providers.
// Server-side handler: verify BEFORE granting. The bonded provider implements
// PaymentProviderInterface; the client sends only an opaque id/receipt, never a
// status or amount. Bonds return null unless the provider confirms a real,
// currently-entitled subscription — so `sub != null` IS the entitlement gate.
import { get } from '@molecule/api-bond'
import type { PaymentProviderInterface } from '@molecule/api-payments'
router.post('/subscriptions/activate', async (req, res) => {
const userId = getUserId(res)
if (!userId) return res.status(401).json({ error: 'Authentication required.' })
// Stripe-style; Apple/Google use payments.verifyReceipt(receipt, productId) /
// payments.verifyPurchase(purchaseToken, productId) with the same null contract.
const payments = get<PaymentProviderInterface>('payments', 'stripe')
const sub = payments?.verifySubscription
? await payments.verifySubscription(req.body.subscriptionId) // opaque `cs_…`/`sub_…` id
: null
if (!sub) {
return res.status(402).json({ error: 'No active subscription.' }) // trust the verify, not the client
}
if (sub.transactionId && (await paymentExists(sub.transactionId))) {
return res.status(409).json({ error: 'This subscription is already linked.' }) // replay guard
}
await grantEntitlement(userId, sub) // store the VERIFIED result server-side
res.json({ expiresAt: sub.expiresAt, autoRenews: sub.autoRenews })
})
core
npm install @molecule/api-payments
CheckoutRedirectOptionsOptions for {@link resolveCheckoutRedirectUrls}.
interface CheckoutRedirectOptions {
/**
* The bond's provider name (e.g. `'stripe'`, `'paypal'`). Added to the
* success URL as `?provider=…` so the app's return page knows which
* provider to verify the purchase with.
*/
provider: string
/**
* The provider's placeholder for the checkout/session id it will substitute
* into the success URL — Stripe's `'{CHECKOUT_SESSION_ID}'`, for example.
* Appended as `&sessionId=…`.
*
* Omit it for providers that append their own identifier parameters to the
* return URL (PayPal adds `subscription_id`/`token`); the app's return page
* accepts those too.
*/
sessionIdToken?: string
}
CheckoutRedirectUrlsResolved redirect URLs for a hosted-checkout handoff.
interface CheckoutRedirectUrls {
/** Where the provider sends the buyer after a completed purchase. */
successUrl: string
/** Where the provider sends the buyer if they abandon the purchase. */
cancelUrl: string
/** The app origin both URLs were built from. */
appOrigin: string
/**
* `true` when no `APP_ORIGIN`/`ORIGIN` was configured and a localhost
* fallback was used. Deployed apps must treat this as a misconfiguration —
* the buyer would be returned to localhost after paying — so bonds log a
* warning naming the missing variable.
*/
usingFallbackOrigin: boolean
}
CreateSetupIntentParamsParameters for creating a SetupIntent (off-session card-save flow).
interface CreateSetupIntentParams {
/**
* The provider customer ID (e.g. Stripe `cus_...`).
*
* If omitted, the provider may create a customer on demand and return its ID
* via {@link SetupIntentResult.customerId}.
*/
customerId?: string
/** Optional metadata to attach to the SetupIntent. */
metadata?: Record<string, string>
/** Optional idempotency key for safe retries. */
idempotencyKey?: string
}
NormalizedPurchaseNormalized 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
}
NormalizedSubscriptionNormalized 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
}
ParsedNotificationParsed server-to-server notification from a payment provider.
interface ParsedNotification {
transactionId?: string
productId?: string
type: string
expiresAt?: string
autoRenews?: boolean
}
PaymentProviderInterfaceFull payment provider bond interface. Each provider implements the methods relevant to its platform; all methods are optional since different platforms (Stripe, Apple, Google) use different flows.
interface PaymentProviderInterface {
readonly providerName: string
/** How this provider's verify flow works. */
readonly verifyFlow?: PaymentVerifyFlow
/** How this provider receives notifications. */
readonly notificationFlow?: PaymentNotificationFlow
/** Verify a subscription by ID (Stripe-style). */
verifySubscription?(subscriptionId: string): Promise<VerifiedSubscription | null>
/** Verify an IAP receipt (Apple-style). */
verifyReceipt?(receipt: string, productId: string): Promise<VerifiedSubscription | null>
/** Verify a purchase (Google-style). */
verifyPurchase?(receipt: string, productId: string): Promise<VerifiedSubscription | null>
/** Handle a webhook event from the provider (Stripe-style). */
handleWebhookEvent?(req: unknown): Promise<WebhookEvent | null>
/** Parse a server-to-server notification (Apple/Google-style). */
parseNotification?(body: unknown): Promise<ParsedNotification | null>
/** Update an existing subscription (change plan) or create a new one. */
updateSubscription?(params: {
userId: string
newProductId: string
previousProductId?: string
/**
* How many units of the plan to bill — seats, on a per-seat plan.
*
* Omitted means one, which is what every flat-priced plan wants. A
* per-seat plan that never sends this bills a single seat no matter how
* many people the app then lets in, so the price the app advertises and the
* price the provider charges quietly disagree.
*/
quantity?: number
}): Promise<SubscriptionUpdateResult>
/** Cancel an existing subscription for a user. */
cancelSubscription?(params: { userId: string }): Promise<boolean>
/**
* Create a customer-portal session (Stripe Billing Portal-style) so the
* user can manage payment methods, cancel, and view invoices in the
* provider's hosted portal. Receipt-based providers (Apple/Google) omit it.
*/
createPortalSession?(params: {
userId: string
returnUrl?: string
}): Promise<{ id: string; url: string } | null>
/**
* Create a SetupIntent for the saved-card flow (Stripe-style).
*
* The frontend confirms the SetupIntent with the provider's client SDK
* using {@link SetupIntentResult.clientSecret}; on success the resulting
* payment-method ID is sent back to the API for persistence.
*/
createSetupIntent?(params: CreateSetupIntentParams): Promise<SetupIntentResult>
/**
* Look up a saved payment method by ID and return normalized card metadata.
*
* Used when a SetupIntent confirms client-side and the resource layer needs
* brand/last4/exp to persist alongside the provider PM ID.
*/
getPaymentMethod?(providerPaymentMethodId: string): Promise<ProviderPaymentMethod | null>
/**
* Detach a saved payment method from its customer (Stripe-style).
*
* Returns `true` on success; returns `false` if the provider call failed.
*/
detachPaymentMethod?(providerPaymentMethodId: string): Promise<boolean>
}
PaymentRecordServicePayment record service for managing payment/transaction records.
interface PaymentRecordService {
store(record: {
userId: string
platformKey: string
transactionId: string
productId: string
data: unknown
receipt?: string
}): Promise<void>
findByTransaction(platformKey: string, transactionId: string): Promise<{ userId: string } | null>
findByCustomerData(
platformKey: string,
key: string,
value: string,
): Promise<{ userId: string } | null>
findByUserId(
userId: string,
platformKey: string,
): Promise<{ data: unknown; transactionId?: string } | null>
deleteByUserId(userId: string): Promise<void>
}
PlanPlan definition for subscription management.
interface Plan {
planKey: string
platformKey: string
platformProductId: string
/**
* The platform's PRICE identifiers that grant this plan (e.g. Stripe
* `price_…` ids). Apps configure prices — not products — in their env,
* so implementations should match an incoming platform identifier against
* `platformProductId` OR membership in this list.
*/
platformPriceIds?: string[]
alias: string
period: string
price: string
autoRenews?: boolean
title: string
description: string
shortDescription?: string
highlightedDescription?: string
capabilities: Record<string, boolean>
}
PlanServicePlan service interface for subscription plan lookups.
interface PlanService {
findPlan(planKey: string): Plan | null
/**
* Finds the plan granted by a platform identifier — the platform's product
* id OR one of the plan's {@link Plan.platformPriceIds}. Callers should try
* every identifier the platform surfaced (product id, price id).
*/
findPlanByProductId(productId: string): Plan | null
getDefaultPlan(): Plan | null
getAllPlans(): Plan[]
/**
* Registers (or replaces) plans in the service's registry, keyed by
* `planKey`. Optional: services with a static catalogue omit it. Billing
* routers call this at wiring time so checkout price ids map back to plans
* at verify/webhook time.
*/
registerPlans?(plans: Record<string, Plan>): void
}
ProviderPaymentMethodCard-style payment method metadata returned by the provider.
interface ProviderPaymentMethod {
/** Provider payment-method ID (e.g. Stripe `pm_...`). */
id: string
/** Card brand (e.g. `visa`, `mastercard`, `amex`). */
brand: string
/** Last four digits of the card. */
last4: string
/** Two-digit expiry month (1–12). */
expMonth: number
/** Four-digit expiry year. */
expYear: number
}
PurchaseVerifier (deprecated)Interface for purchase verification (one-time purchases).
interface PurchaseVerifier {
/**
* Verifies a one-time purchase and returns normalized data, or `null` if invalid.
*/
verifyPurchase(productId: string, token: string): Promise<NormalizedPurchase | null>
}
ResolvedAppOriginThe app origin a provider should return the buyer to.
interface ResolvedAppOrigin {
/** The origin, with any trailing slash removed. */
appOrigin: string
/**
* `true` when no `APP_ORIGIN`/`ORIGIN` was configured and a localhost
* fallback was used. Deployed apps must treat this as a misconfiguration —
* the buyer would be returned to localhost after paying — so bonds log a
* warning naming the missing variable.
*/
usingFallbackOrigin: boolean
}
SetupIntentResultResult of creating a SetupIntent.
interface SetupIntentResult {
/** Provider SetupIntent ID (e.g. Stripe `seti_...`). */
id: string
/**
* The client secret used by the frontend SDK to confirm the SetupIntent.
*
* For Stripe, this is consumed by `stripe.confirmCardSetup(clientSecret, ...)`.
*/
clientSecret: string
/**
* The provider customer ID this SetupIntent is attached to.
*
* Returned even when the caller didn't provide one — the provider may create
* a customer on demand and the resource layer will persist the ID.
*/
customerId: string
}
SubscriptionUpdateResultResult of updating or creating a subscription.
interface SubscriptionUpdateResult {
/** Whether the subscription was successfully updated in-place. */
updated: boolean
/** If a checkout is required (new subscription), the URL to redirect to. */
checkoutUrl?: string
/** Updated subscription details when the update succeeded. */
subscription?: {
expiresAt?: string
autoRenews?: boolean
}
}
SubscriptionVerifier (deprecated)Interface for subscription verification.
interface SubscriptionVerifier {
/**
* Verifies a subscription and returns normalized data, or `null` if invalid.
*/
verifySubscription(productId: string, token: string): Promise<NormalizedSubscription | null>
}
TaggedErrorA "tagged error" — the convention @molecule/api-secrets's
configNotConfiguredError() and @molecule/api-resource's respondError()
use to carry an HTTP status + a machine-readable key on an Error so a
caught error can be re-surfaced with its REAL status instead of being
flattened to a generic failure.
interface TaggedError extends Error {
/** HTTP status the error should be reported with (e.g. `503`). */
statusCode: number
/** Machine-readable key the frontend/operator maps to a specific cause. */
errorKey: string
}
VerifiedSubscriptionResult of verifying a subscription or receipt.
interface VerifiedSubscription {
productId: string
/**
* The provider's PRICE identifier for the purchased plan (e.g. a Stripe
* `price_…` id), when the provider distinguishes prices from products.
*
* Apps typically configure their plan catalogue with price ids (that is
* what checkout is started with and what env vars like
* `STRIPE_<APP>_PRO_MONTHLY` hold), while providers report the parent
* product id on subscriptions — so plan resolution should try BOTH
* `productId` and `priceId` against the registered plans.
*/
priceId?: string
transactionId?: string
expiresAt?: string
autoRenews?: boolean
data?: unknown
}
WebhookEventParsed webhook event from a payment provider.
interface WebhookEvent {
type: string
subscription?: {
customerId?: string
productId?: string
/**
* The provider's PRICE identifier for the subscribed plan (e.g. a Stripe
* `price_…` id). Apps register their plan catalogue with price ids (see
* {@link VerifiedSubscription.priceId}), so plan resolution should try
* BOTH `productId` and `priceId`.
*/
priceId?: string
expiresAt?: string
autoRenews?: boolean
/**
* Normalized subscription status at the time of the event.
*
* Surfaced so the notification handler can apply the SAME entitlement gate
* the verify path uses: only an active/trialing subscription confers the
* plan. A past_due/unpaid/incomplete subscription (e.g. a renewal-payment
* failure that still advances the period end) must NOT extend entitlement.
*/
status?: SubscriptionStatus
/**
* Whether the subscription is currently active (status active/trialing).
*
* Mirrors {@link NormalizedSubscription.isActive}. When `false`, the
* notification handler must not grant/extend the plan.
*/
isActive?: boolean
}
}
PaymentNotificationFlowHow a payment provider receives notifications.
'webhook' — Stripe-style: signed webhook with event type in payload'server-notification' — Apple/Google-style: server-to-server notification bodytype PaymentNotificationFlow = 'webhook' | 'server-notification'
PaymentProviderPayment 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
PaymentProviderNamePayment provider name.
Open string type so new providers can be added without modifying the core. Well-known values include 'stripe', 'apple', and 'google'.
type PaymentProviderName = string
PaymentProviderTypeAlias for PaymentProviderName; see PaymentProviderInterface for the bond interface.
type PaymentProviderType = PaymentProviderName
PaymentVerifyFlowHow a payment provider's verify endpoint should be invoked.
'subscription' — Stripe-style: client sends subscriptionId'receipt' — Apple/Google-style: client sends receipt + planKeytype PaymentVerifyFlow = 'subscription' | 'receipt'
SubscriptionStatusSubscription status across providers.
type SubscriptionStatus =
'active' | 'canceled' | 'expired' | 'past_due' | 'trialing' | 'paused' | 'pending' | 'unknown'
isActiveStatus(status)Checks whether a subscription status represents an active subscription.
Returns true for 'active' and 'trialing' statuses.
function isActiveStatus(status: SubscriptionStatus): boolean
status — The subscription status to check.Returns: true if the subscription is active or trialing.
isConfigNotConfiguredError(error)Checks whether a caught value is the tagged "secret not configured" error
thrown by @molecule/api-secrets's configNotConfiguredError() — e.g. a
payment bond's getClient()-style helper throwing because STRIPE_SECRET_KEY
(or APPLE_SHARED_SECRET, GOOGLE_API_SERVICE_KEY_OBJECT, …) is unset.
function isConfigNotConfiguredError(error: unknown): boolean
error — The caught value (any type — callers narrow with this predicate).Returns: true if error carries statusCode: 503 and errorKey: 'config.notConfigured'.
resolveAppOrigin()Resolves the origin the app is served from: APP_ORIGIN, else ORIGIN, else
a localhost fallback on the conventional dev frontend port (the API PORT
minus 1000, e.g. 4030 → 3030).
This is the origin the browser holds session cookies for, so it is the only safe target for any provider redirect that must arrive authenticated.
function resolveAppOrigin(): ResolvedAppOrigin
Returns: The app origin and whether it came from the localhost fallback.
resolveBillingPortalReturnUrl(returnPath)Builds the URL a hosted billing portal returns the user to when they exit.
Same rule as checkout: the destination is the APP origin, so the returning
browser is authenticated. returnPath is accepted from the caller (the page
that opened the portal, so the user lands back where they were) but is only
honored when it is a same-origin ABSOLUTE PATH — a value like
https://evil.example or //evil.example would otherwise turn this into an
open redirect off a provider's domain.
function resolveBillingPortalReturnUrl(returnPath?: string): string
returnPath — Optional app-relative path to return to (e.g. /billing). Falls back to PAYMENTS_BILLING_RETURN_PATH, then the app root.Returns: The absolute return URL.
resolveCheckoutRedirectUrls(options)Builds the success/cancel URLs a hosted checkout returns the buyer to.
Both URLs point at the APP origin, never the API origin. The session
cookies that authenticate a browser are set for the app's host, so a
top-level redirect from the provider's domain to a different API host
arrives with no credentials and any authenticated callback there answers
401 — the buyer pays and lands on an error page with no plan granted. The
app page reached instead calls
POST /users/:id/verify-payment/:provider with the id in the query, from
the app origin, where the credentials do apply.
Routes are configurable because only the app knows them:
PAYMENTS_PLAN_UPDATED_PATH (default /plan-updated) and
PAYMENTS_CHECKOUT_CANCEL_PATH (default: the app root).
function resolveCheckoutRedirectUrls(options: CheckoutRedirectOptions): CheckoutRedirectUrls
options — The provider name and its session-id placeholder.Returns: The success/cancel URLs, the app origin, and whether a localhost fallback origin had to be used.
DEFAULT_PLAN_UPDATED_PATHDefault route the buyer is returned to after a completed checkout.
const DEFAULT_PLAN_UPDATED_PATH: '/plan-updated'
| Provider | Package |
|---|---|
| Apple IAP | @molecule/api-payments-apple |
| Google Play | @molecule/api-payments-google |
| PayPal | @molecule/api-payments-paypal |
| Stripe | @molecule/api-payments-stripe |
Entitlement is granted ONLY on a server-verified payment — NEVER trust the
client. A browser can claim any subscription status, product id, or "I paid". The
one source of truth is a verification performed in YOUR API: pass the provider's
opaque token/receipt/subscription id to the bonded provider's verify method —
{@link PaymentProviderInterface.verifySubscription} (Stripe-style: the cs_…/sub_…
id), {@link PaymentProviderInterface.verifyReceipt} (Apple: base64 receipt +
productId), or {@link PaymentProviderInterface.verifyPurchase} (Google: purchase
token + productId). Each calls the provider server-side and returns a
{@link VerifiedSubscription} — or null when the purchase is invalid OR not
entitled (expired, refunded, past_due, pending). Grant access from THAT result,
never from a value the client sent.
The shipped bonds implement {@link PaymentProviderInterface} — that is what
bond('payments', provider) / get('payments', name) hands you, and its verify
methods take the OPAQUE id/receipt only. The two-argument
{@link SubscriptionVerifier}/{@link PurchaseVerifier} interfaces (returning
{@link NormalizedSubscription}/{@link NormalizedPurchase}) are @deprecated
auxiliary abstractions for app-level services; no @molecule/api-payments-* bond
implements them — do not call verifySubscription(productId, token) on a bonded
provider (the extra argument is silently ignored and the lookup fails).
A missing secret (STRIPE_SECRET_KEY, APPLE_SHARED_SECRET,
GOOGLE_API_SERVICE_KEY_OBJECT, …) is a DIFFERENT failure than "not entitled".
The shipped bonds rethrow a tagged config-not-configured error (see
{@link isConfigNotConfiguredError}) from their verify/update/cancel methods
instead of swallowing it into the same null result a genuine verification
failure returns — a resource-layer catch block MUST check
isConfigNotConfiguredError(error) and pass its statusCode/errorKey
through (rather than flattening to a generic 400/500) so the actionable
"which key, where to get it" message reaches the caller instead of only the
server log.
Things a weak integration gets wrong — do NOT:
amount/price from the client. The server owns the price (look it up by
product/price id); a client-supplied amount is a tampering vector.transactionId /
subscriptionId and reject a replay (one receipt must not unlock two accounts).whsec_…, Apple/Google
notifications) MUST have its signature verified before you act on it — an unverified
webhook body is attacker-controlled.sk_…) to the browser. Only the publishable key is
client-side; verification + secret keys stay in the API.When you DO handle a normalized status (a {@link NormalizedSubscription} or a
webhook's {@link WebhookEvent.subscription}), use {@link isActiveStatus} (true for
active/trialing) instead of hand-checking status strings, and store the
verified result server-side keyed by user.
A hosted checkout returns the buyer to the APP, not the API. Bonds build
their success/cancel URLs with {@link resolveCheckoutRedirectUrls}, which points
at APP_ORIGIN + /plan-updated?provider=…&sessionId=… (both routes
configurable — see that function). Session cookies belong to the app's host, so
a top-level redirect to a separate API host carries no credentials and an
authenticated callback there answers 401: the buyer pays and lands on an error
page with nothing granted. The app's return page is what calls
POST /users/:id/verify-payment/:provider with the id from the query, from an
origin where the credentials apply.
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:
read_activity tool (filter type 'webhook'); never
mock the event or modify production code to fake an entitlement.