@apexara/stripe
v1.0.3
Published
Stripe service layer and webhook dispatcher
Readme
@apexara/stripe
A typed Stripe service layer for Node.js. Wraps the official Stripe SDK into focused, independently injectable services with built-in webhook handling, idempotency, and affiliate payout math.
Contents
Installation
npm install @apexara/stripeExpress is an optional peer dependency — only required if you use StripeWebhookHandler:
npm install expressRequirements: Node.js 18+, TypeScript 5+
Quick Start
import { ApexStripe } from '@apexara/stripe';
const stripe = new ApexStripe(
{ secretKey: process.env.STRIPE_SECRET_KEY! },
{ currency: 'usd' },
);
// Create a customer
const customer = await stripe.customers.createCustomer('Jane', 'Doe', '[email protected]');
// Create a checkout session
const clientSecret = await stripe.checkout.createCheckoutSession({
mode: 'payment',
customerId: customer.id,
name: 'Order #1234',
cost: 4900, // $49.00 in cents
quantity: 1,
returnUrl: 'https://example.com/payment-result',
});
// Access the raw Stripe client if needed
const balance = await stripe.client.balance.retrieve();Configuration
StripeClientConfig
Passed as the first argument to ApexStripe. Controls how the Stripe SDK client is created.
interface StripeClientConfig {
secretKey: string;
apiVersion?: Stripe.LatestApiVersion; // Default: '2025-08-27.basil'
}StripeServiceConfig
Passed as the second (optional) argument to ApexStripe. All fields are optional — defaults are applied for any field not provided.
interface StripeServiceConfig {
// Stripe settings
currency?: string; // Default: 'usd'
// Payment methods per context
orderPaymentMethods?: string[]; // Default: ['card', 'link', 'cashapp', 'klarna']
subscriptionPaymentMethods?: string[]; // Default: ['card', 'link', 'cashapp']
setupIntentPaymentMethods?: string[]; // Default: ['card']
// Checkout behavior
checkoutUiMode?: 'custom' | 'embedded' | 'hosted'; // Default: 'custom'
allowPromotionCodes?: boolean; // Default: true
savePaymentMethod?: boolean; // Default: true
orderInvoiceCreation?: boolean; // Default: true
// Invoices
invoiceCollectionMethod?: 'send_invoice' | 'charge_automatically'; // Default: 'send_invoice'
invoiceDaysUntilDue?: number; // Default: 3
invoicePaymentMethodTypes?: string[]; // Default: ['card', 'link']
// Connected accounts
connectedAccountType?: 'express' | 'standard' | 'custom'; // Default: 'express'
}Full example with all defaults overridden:
const stripe = new ApexStripe(
{ secretKey: process.env.STRIPE_SECRET_KEY! },
{
currency: 'eur',
orderPaymentMethods: ['card'],
subscriptionPaymentMethods: ['card'],
setupIntentPaymentMethods: ['card'],
checkoutUiMode: 'embedded',
allowPromotionCodes: false,
savePaymentMethod: false,
orderInvoiceCreation: false,
invoiceCollectionMethod: 'charge_automatically',
invoiceDaysUntilDue: 7,
invoicePaymentMethodTypes: ['card'],
connectedAccountType: 'standard',
},
);Services
All services are available as properties on the ApexStripe instance. They can also be instantiated independently — see Advanced Usage.
CheckoutService
stripe.checkout
Handles Stripe Checkout sessions, Setup Intents, and affiliate payout calculations.
createCheckoutSession(params)
Creates a payment or subscription checkout session. Returns the client_secret for use with Stripe.js.
Payment mode:
const clientSecret = await stripe.checkout.createCheckoutSession({
mode: 'payment',
customerId: 'cus_xxx',
name: 'Order #1234',
cost: 4900, // Amount in cents
quantity: 1,
returnUrl: 'https://example.com/result',
metadata: { orderId: '1234' },
// Optional: affiliate payout
affiliate: {
accountId: 'acct_xxx', // Connected account ID
amount: 490, // Payout amount in cents
},
});Subscription mode:
const clientSecret = await stripe.checkout.createCheckoutSession({
mode: 'subscription',
customerId: 'cus_xxx',
priceIds: ['price_xxx'],
quantity: 1,
returnUrl: 'https://example.com/result',
metadata: { userId: 'user_123' },
// Optional: affiliate payout
affiliate: {
transferData: { destination: 'acct_xxx', amount_percent: 19 },
metadata: { affiliateId: 'aff_xxx' },
},
});createSetupIntentSession(customerId)
Creates a Setup Intent for saving a payment method without charging. Returns the client_secret.
const clientSecret = await stripe.checkout.createSetupIntentSession('cus_xxx');expireCheckoutSession(sessionId)
Expires an open checkout session. Silently ignores sessions that are already expired — safe to call unconditionally.
await stripe.checkout.expireCheckoutSession('cs_xxx');retrieveCheckoutSession(sessionId, options?)
Retrieves a checkout session with optional expansion.
const session = await stripe.checkout.retrieveCheckoutSession('cs_xxx', {
expand: ['payment_intent.latest_charge', 'invoice'],
});getPaymentIntent(paymentIntentId, options?)
Retrieves a payment intent.
const intent = await stripe.checkout.getPaymentIntent('pi_xxx');CustomerService
stripe.customers
createCustomer(firstName, lastName, email)
Creates a Stripe customer. The full name is stored as ${firstName} ${lastName}.
const customer = await stripe.customers.createCustomer('Jane', 'Doe', '[email protected]');
// → Stripe.CustomerupdateCustomerDefaultPaymentMethod(customerId, paymentMethodId)
Sets the default payment method on a customer's invoice settings.
await stripe.customers.updateCustomerDefaultPaymentMethod('cus_xxx', 'pm_xxx');InvoiceService
stripe.invoices
createInvoice(params)
Creates an invoice with a single line item and either sends it or finalizes it, depending on invoiceCollectionMethod.
send_invoice— creates, adds line item, sends — customer receives an email with payment linkcharge_automatically— creates, adds line item, finalizes — Stripe charges the default payment method
const invoice = await stripe.invoices.createInvoice({
customerId: 'cus_xxx',
amount: 2000, // In cents
description: 'Credit top-up — 500 records',
metadata: { origin: 'admin', credits: '500' },
});
// → Stripe.Invoice (sent or finalized)listInvoicePayments(invoiceId, params?)
Lists all payments recorded against an invoice.
const payments = await stripe.invoices.listInvoicePayments('in_xxx');
// → Stripe.ApiList<Stripe.InvoicePayment>findPendingActivationInvoice(customerId)
Checks whether a customer has an open invoice from a subscription_create event whose subscription is still active or incomplete. Returns the invoice data or null.
Use this to prompt the user to pay their pending subscription invoice after an admin-created invoice-based subscription.
const pending = await stripe.invoices.findPendingActivationInvoice('cus_xxx');
if (pending) {
// { subscriptionId: 'sub_xxx', hostedInvoiceUrl: 'https://...' }
redirectToInvoice(pending.hostedInvoiceUrl);
}Returns null when:
- No open invoices exist
- No invoice has
billing_reason === 'subscription_create' - The subscription is
canceledorincomplete_expired
PaymentLinkService
stripe.paymentLinks
createPaymentLink(params)
Creates a Stripe Price and Payment Link in one call. Returns the link ID, URL, and the underlying price/product IDs needed to archive later.
const link = await stripe.paymentLinks.createPaymentLink({
name: 'Order #1234',
cost: 4900,
redirectUrl: 'https://example.com/result',
metadata: { orderId: '1234' },
// Optional: affiliate payout
affiliate: {
accountId: 'acct_xxx',
amount: 490,
},
});
// → { id, url, productId, priceId }archivePaymentLink(linkId, productId, priceId)
Deactivates the payment link, product, and price so they no longer appear in Stripe or accept payments.
await stripe.paymentLinks.archivePaymentLink(
link.id,
link.productId,
link.priceId,
);PaymentMethodService
stripe.paymentMethods
getPaymentMethod(paymentMethodId)
const pm = await stripe.paymentMethods.getPaymentMethod('pm_xxx');
// → Stripe.PaymentMethodlistPaymentMethods(customerId)
Returns the customer's saved payment methods as a flat array.
const methods = await stripe.paymentMethods.listPaymentMethods('cus_xxx');
// → Stripe.PaymentMethod[]attachPaymentMethod(paymentMethodId, customerId)
Attaches a payment method to a customer.
const pm = await stripe.paymentMethods.attachPaymentMethod('pm_xxx', 'cus_xxx');setDefaultPaymentMethodForCustomerAndSubscription(customerId, subscriptionId, paymentMethodId)
Attaches the payment method, sets it as the customer's invoice default, and sets it as the subscription's default — all in one call.
await stripe.paymentMethods.setDefaultPaymentMethodForCustomerAndSubscription(
'cus_xxx',
'sub_xxx',
'pm_xxx',
);setDefaultPaymentMethod(subscriptionId, paymentMethodId)
Sets the subscription's default payment method unconditionally.
await stripe.paymentMethods.setDefaultPaymentMethod('sub_xxx', 'pm_xxx');setDefaultPaymentMethodIfMissing(subscriptionId, paymentMethodId)
Sets the default only if the subscription has no default payment method currently set.
const { changed } = await stripe.paymentMethods.setDefaultPaymentMethodIfMissing('sub_xxx', 'pm_xxx');removePaymentMethod(customerId, subscriptionId, paymentMethodId)
Removes a payment method safely. If it is the subscription's default, another payment method is assigned first. Throws if it is the only payment method on the customer.
const { newDefaultId } = await stripe.paymentMethods.removePaymentMethod(
'cus_xxx',
'sub_xxx',
'pm_xxx',
);PriceService
stripe.prices
createCustomSubscriptionPrice(params)
Creates a recurring price on an existing Stripe product.
const price = await stripe.prices.createCustomSubscriptionPrice({
cost: 9900, // In cents
billingPeriod: 'month',
billingIntervalCount: 1,
label: 'Pro Monthly',
stripeProductId: 'prod_xxx',
metadata: { label: 'Pro Monthly', credits: '1000' },
});
// → Stripe.PricebillingPeriod accepts: 'day' | 'week' | 'month' | 'year'
archivePrice(priceId)
Sets a price to active: false so it can no longer be used for new subscriptions.
await stripe.prices.archivePrice('price_xxx');ConnectedAccountService
stripe.connectedAccounts
Handles Stripe Connect for marketplace and affiliate payout scenarios.
createConnectedAccount(metadata?)
Creates a new connected account (type from config, defaults to 'express'). Returns the account ID.
const accountId = await stripe.connectedAccounts.createConnectedAccount({
userId: 'user_123',
});
// → 'acct_xxx'deleteConnectedAccount(accountId)
Permanently deletes a connected account.
await stripe.connectedAccounts.deleteConnectedAccount('acct_xxx');createAccountLink(accountId, refreshUrl, returnUrl, type?)
Generates a Stripe-hosted onboarding or management URL for a connected account. Returns the URL string.
const url = await stripe.connectedAccounts.createAccountLink(
'acct_xxx',
'https://example.com/onboarding/refresh',
'https://example.com/onboarding/return',
// type defaults to 'account_onboarding'
);type accepts: 'account_onboarding' | 'account_update'
createLoginLink(accountId)
Generates a single-use Express Dashboard login URL for a connected account. Returns the URL string.
const url = await stripe.connectedAccounts.createLoginLink('acct_xxx');SubscriptionService
stripe.subscriptions
The most comprehensive service. Covers the full subscription lifecycle including scheduling, upgrades, downgrades, and payment method management.
Basic operations
// Get a subscription (expands items.data.price)
const sub = await stripe.subscriptions.getSubscriptionById('sub_xxx');
// Get all subscriptions for a customer (status: 'all', expands default_payment_method)
const subs = await stripe.subscriptions.getAllUserSubscriptions('cus_xxx');
// → Stripe.Subscription[] (empty array if customerId is falsy)
// Update a subscription
const updated = await stripe.subscriptions.updateSubscription('sub_xxx', {
metadata: { userId: 'user_123' },
});
// Cancel immediately
const cancelled = await stripe.subscriptions.cancelSubscription('sub_xxx');Lifecycle — cancel at period end / resume
// Schedule cancellation at end of billing period.
// Throws if status is not 'active' or 'past_due'.
const sub = await stripe.subscriptions.cancelSubscriptionAtPeriodEnd('sub_xxx');
// Undo a scheduled cancellation.
// Throws if cancel_at_period_end is false or status is not resumable.
const sub = await stripe.subscriptions.resumeSubscription('sub_xxx');Plan switching and credits
Use applyPlanCredit to create a credit invoice item (a negative charge) before switching plans, so the customer is credited for unused days on their current plan:
await stripe.subscriptions.applyPlanCredit(
'cus_xxx',
'sub_xxx',
3200, // Amount in cents — stored as a negative invoice item
'usd',
'Credit for unused Pro plan days', // Optional
{ type: 'upgrade_credit' }, // Optional metadata
);Then switch the subscription to the new price immediately (billing cycle resets to now, no proration by default):
const updated = await stripe.subscriptions.switchSubscriptionPlan(
'sub_xxx',
'si_xxx', // The subscription item being replaced
'price_xxx',
{
quantity: 1, // Optional, defaults to 1
proration: 'none', // Optional — pass 'create_prorations' to let Stripe calculate
},
);Downgrades (scheduled)
Downgrades are scheduled to take effect at the end of the current billing period using Stripe Subscription Schedules.
// Schedule a downgrade to a lower price at period end.
// Tags the schedule with { type: 'downgrade' } for later identification.
const schedule = await stripe.subscriptions.scheduleDowngrade(
subscription,
'price_lower_xxx',
{
transferData: { destination: 'acct_xxx', amount_percent: 19 }, // Optional
phaseMetadata: { affiliateId: 'aff_xxx' }, // Optional
scheduleMetadata: { initiatedBy: 'user' }, // Optional
},
);
// Check if a downgrade is scheduled (returns null if not, or if the
// schedule is not tagged as a downgrade).
const schedule = await stripe.subscriptions.getDowngradeSchedule(subscription);
if (schedule) {
// schedule.phases[1].items[0].price → the new lower price
}
// Cancel a scheduled downgrade (e.g. the user upgrades instead).
// Returns true if a downgrade schedule was found and released, false otherwise.
const released = await stripe.subscriptions.releaseScheduleIfDowngrade(subscription);For more control, get or create the schedule directly:
const schedule = await stripe.subscriptions.getOrCreateSubscriptionSchedule(subscription);Invoice-based subscriptions (admin-created)
Creates a subscription using send_invoice collection and immediately emails the invoice to the customer. Use this for admin-created subscriptions where the customer pays via an emailed link rather than through a checkout session.
const { subscription, invoiceId, invoiceUrl } =
await stripe.subscriptions.createInvoiceBasedSubscription(
'cus_xxx',
'price_xxx',
{
daysUntilDue: 3, // Overrides config default
paymentMethodTypes: ['card', 'link'],
metadata: { adminId: 'admin_123', userId: 'user_456' },
transferData: { destination: 'acct_xxx', amount_percent: 19 }, // Optional affiliate
quantity: 1,
},
);
// invoiceUrl — the hosted_invoice_url to show the customer
// invoiceId — the Stripe invoice IDUtilities
// Extract fields needed to keep a local subscription document in sync.
// Falls back to provided values when the subscription has no items yet.
const fields = stripe.subscriptions.buildSubscriptionSyncFields(subscription, {
currentPeriodStart: existingDoc.currentPeriodStart,
currentPeriodEnd: existingDoc.currentPeriodEnd,
itemId: existingDoc.itemId,
});
// → { status, cancelAtPeriodEnd, currentPeriodStart, currentPeriodEnd, itemId }
// Get the primary subscription item, preferring a known item ID.
const item = stripe.subscriptions.getPrimarySubscriptionItem(subscription, 'si_xxx');
// → Stripe.SubscriptionItem | nullAffiliate Payout Helpers
Two standalone functions are exported for calculating affiliate payouts after deducting Stripe processing fees. Both require a processingFees object ({ flat: number, percent: number }).
import {
calculateAffiliatePaymentPayoutAmount,
calculateAffiliateSubscriptionPayoutPercent,
} from '@apexara/stripe';
const fees = { flat: 30, percent: 0.029 }; // Standard Stripe rates
// One-time payment payout — returns flat amount in cents
// cost = 10000 ($100), affiliatePayoutPercent = 20
// stripeFee = round(10000 * 0.029 + 30) = 320
// payout = round((10000 - 320) * 0.20) = 1936 ($19.36)
const amount = calculateAffiliatePaymentPayoutAmount(10000, 20, fees);
// → 1936
// Subscription payout — returns percentage to pass to transfer_data.amount_percent
// subscriptionCost = 10000, payoutPercent = 0.20
// result = ((10000 - 320) * 0.20) / 10000 ≈ 0.19
const percent = calculateAffiliateSubscriptionPayoutPercent(10000, 0.20, fees);
// → 0.19Webhooks
StripeWebhookHandler handles the main Stripe webhook and the connected account webhook. It performs signature verification, idempotency checks, and event dispatch.
Setup
import { StripeWebhookHandler, WebhookHooks, WebhookHandlerConfig } from '@apexara/stripe';
const config: WebhookHandlerConfig = {
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
connectedWebhookSecret: process.env.STRIPE_WEBHOOK_CONNECTED_SECRET, // Optional
};
const hooks: WebhookHooks = {
// Required: pluggable idempotency store
eventStore: {
tryInsert: async (eventId, type) => {
// Return true if this is a new event (insert succeeded).
// Return false if the event was already processed (duplicate).
// Use a unique index to make this atomic.
return db.stripeEvents.tryInsert({ eventId, type });
},
},
// Optional: implement only the events you care about
onCheckoutCompleted: async ({ session, metadata }) => {
// session is pre-expanded: payment_intent.latest_charge + invoice
await handleOrder(session, metadata);
},
onInvoicePaid: async ({ event, invoice }) => {
await grantCredits(invoice);
},
onInvoicePaymentFailed: async ({ event, invoice, failReason }) => {
// failReason is pre-extracted from the payment intent (decline_code or error code)
await notifyUser(invoice, failReason);
},
onSubscriptionDeleted: async ({ event, subscription }) => {
await deactivateSubscription(subscription.id);
},
onConnectedAccountUpdated: async ({ account }) => {
await syncAffiliateStatus(account);
},
// Optional: called when a non-critical internal error occurs (e.g. fail reason extraction)
onError: (context, error) => {
logger.error(`Webhook error in ${context}`, error);
},
};
const handler = new StripeWebhookHandler(
hooks,
stripeClient, // Raw Stripe instance — use ApexStripe.client or createStripeClient()
config,
checkoutService, // ApexStripe.checkout or a standalone CheckoutService
invoiceService, // ApexStripe.invoices or a standalone InvoiceService
);Express routes
The handler plugs directly into Express. The raw body must be available on req — register express.raw() before the webhook routes.
app.use('/stripe/webhook', express.raw({ type: 'application/json' }));
app.use('/stripe/webhook/connected', express.raw({ type: 'application/json' }));
// Express mode — pass next to delegate errors to the app's error middleware
app.post('/stripe/webhook', (req, res, next) => handler.handle(req, res, next));
app.post('/stripe/webhook/connected', (req, res, next) => handler.handleConnected(req, res, next));
// Standalone mode — omit next and the handler manages error responses itself
app.post('/stripe/webhook', (req, res) => handler.handle(req, res));Idempotency
Every incoming event is checked against the event store before being dispatched. If tryInsert returns false, the event is acknowledged with HTTP 200 and skipped — no hook is called. This prevents duplicate processing when Stripe retries delivery.
The tryInsert implementation must be atomic. Use a unique index (MongoDB, PostgreSQL) or a Redis SET NX to avoid race conditions when Stripe delivers the same event to multiple server instances simultaneously.
MongoDB example:
eventStore: {
tryInsert: async (eventId, type) => {
try {
await StripeEventModel.create({ eventId, type, processedAt: new Date() });
return true;
} catch (err: any) {
if (err.code === 11000) return false; // Duplicate key — already processed
throw err;
}
},
},Events dispatched
| Stripe event | Hook called | Notes |
|---|---|---|
| checkout.session.completed | onCheckoutCompleted | Session re-fetched and expanded with payment_intent.latest_charge and invoice before the hook is called |
| invoice.paid | onInvoicePaid | Raw invoice from event data |
| invoice.payment_failed | onInvoicePaymentFailed | Raw invoice + failReason pre-extracted from the payment intent (decline_code or code) |
| customer.subscription.deleted | onSubscriptionDeleted | Raw subscription from event data |
| account.updated (connected) | onConnectedAccountUpdated | Raw account from event data |
| All other event types | — | Silently ignored — HTTP 200 returned. Throwing for unknown events would cause Stripe to retry for up to 72 hours. |
Advanced Usage
Using services independently
Every service can be instantiated on its own with an injected Stripe client. This is useful when you only need part of the package or when injecting the client from elsewhere.
import Stripe from 'stripe';
import { createStripeClient, SubscriptionService, InvoiceService } from '@apexara/stripe';
const client = createStripeClient({ secretKey: process.env.STRIPE_SECRET_KEY! });
// Services that need config
const subscriptions = new SubscriptionService(client, {
currency: 'usd',
invoiceDaysUntilDue: 7,
invoiceCollectionMethod: 'send_invoice',
invoicePaymentMethodTypes: ['card', 'link'],
// All other StripeServiceConfig fields are required when constructing directly —
// use ApexStripe to have defaults applied automatically.
});
// Services that only need the client
import { CustomerService, PaymentMethodService } from '@apexara/stripe';
const customers = new CustomerService(client);
const paymentMethods = new PaymentMethodService(client);Using the raw Stripe client
The raw Stripe instance is accessible via ApexStripe.client:
const apex = new ApexStripe({ secretKey: '...' });
// Full Stripe SDK — use for anything not covered by the services
const products = await apex.client.products.list({ limit: 10 });Or create it independently:
import { createStripeClient } from '@apexara/stripe';
const stripe = createStripeClient({
secretKey: process.env.STRIPE_SECRET_KEY!,
apiVersion: '2025-08-27.basil', // Optional
});API Reference — Types
All types are exported from the package root.
Config
import {
StripeClientConfig,
StripeServiceConfig,
StripeProcessingFees,
WebhookHandlerConfig,
} from '@apexara/stripe';Checkout
import {
CreateCheckoutSessionParams,
CreatePaymentCheckoutSessionParams,
CreateSubscriptionCheckoutSessionParams,
AffiliatePaymentParams,
AffiliateSubscriptionParams,
} from '@apexara/stripe';Subscriptions
import {
ScheduleDowngradeOptions,
SwitchSubscriptionPlanOptions,
CreateInvoiceSubscriptionOptions,
CreateInvoiceSubscriptionResult,
SubscriptionSyncFields,
} from '@apexara/stripe';Invoices
import {
CreateInvoiceParams,
PendingActivationInvoice,
} from '@apexara/stripe';Payment links
import {
CreatePaymentLinkParams,
PaymentLinkResult,
} from '@apexara/stripe';Prices
import { CreateCustomPriceParams } from '@apexara/stripe';Webhooks
import {
WebhookHooks,
WebhookEventStore,
CheckoutCompletedPayload,
InvoicePaidPayload,
InvoicePaymentFailedPayload,
SubscriptionDeletedPayload,
ConnectedAccountUpdatedPayload,
} from '@apexara/stripe';Utilities
import { getBillingCycleLabel } from '@apexara/stripe';
getBillingCycleLabel('month', 1); // → 'Monthly'
getBillingCycleLabel('year', 1); // → 'Yearly'
getBillingCycleLabel('week', 1); // → 'Weekly'
getBillingCycleLabel('day', 1); // → 'Daily'
getBillingCycleLabel('month', 3); // → 'Quarterly'
getBillingCycleLabel('month', 6); // → 'Semi-Annually'
getBillingCycleLabel('month', 2); // → 'Every 2 months'Development
# Install dependencies
npm install
# Build
npm run build
# Build in watch mode
npm run build:watch
# Type check (no emit)
npm run type-check
# Run tests
npm test
# Run tests with coverage
npm run test:coverageTest stack: Vitest with v8 coverage. Tests live in src/tests/ and are excluded from the build output.
Stripe API version: 2025-08-27.basil — pinned in src/client.ts. To use a different version, pass apiVersion in StripeClientConfig.
License
ISC
