@shware/purchase
v11.0.0
Published
Readme
@shware/purchase
Shared building blocks for selling subscriptions and credits on Stripe, the App Store and Google
Play. The root export holds a platform-neutral subscription model (statuses, cancellation details)
and the three product catalogs (StripeConfig, AppStoreConfig, GooglePlayConfig) that map
store product ids to your plans, billing periods and credit grants. The platform subpaths add
typed event dispatchers and mappers from each store's payloads onto that model. Verifying webhook
signatures and calling store APIs stays in your app, with the official SDKs.
Install
pnpm add @shware/purchase zod
# optional peers, only for the subpaths you use
pnpm add stripe # @shware/purchase/stripe (^23.0.0)
pnpm add @apple/app-store-server-library # @shware/purchase/app-store (^3.1.0)Entry points
| Import path | Contents |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| @shware/purchase | Subscription model (PLATFORMS, SUBSCRIPTION_STATUSES, AVAILABLE_STATUSES, cancellation types) and the three configs |
| @shware/purchase/stripe | StripeThinEventHandler, StripeEventHandler, Stripe mappers, checkout schemas, METADATA_KEYS, analytics helpers, browser pending-checkout helpers |
| @shware/purchase/app-store | AppStoreNotificationHandler for App Store Server Notifications V2, mapCancellationDetails |
| @shware/purchase/google-play | Real-time developer notification (RTDN) types and enums, subscription state and cancellation mappers |
Subscription model
import {
AVAILABLE_STATUSES,
PLATFORMS,
SUBSCRIPTION_STATUSES,
type CancellationDetails,
type Platform,
type SubscriptionStatus,
} from '@shware/purchase';PLATFORMS:'APPLE' | 'GOOGLE' | 'STRIPE'.SUBSCRIPTION_STATUSES: the union of every store's states (TRIALING,ACTIVE,IN_GRACE_PERIOD,PAST_DUE,BILLING_RETRY,ON_HOLD,PAUSED,CANCELED,EXPIRED, ...). Each platform mapper returns one of these.SubscriptionState: what each platform'smapSubscriptionreturns,{ status, autoRenewEnabled, trialStart, trialEnd }, so the same situation reads the same everywhere: a customer who turned auto-renew off isACTIVEwithautoRenewEnabled: falseuntil the period runs out (Stripe'scancel_at_period_end, Apple'sautoRenewStatusoff, Play'sSUBSCRIPTION_STATE_CANCELED), and a free trial isTRIALINGwith its dates on all three.CANCELEDis Stripe'scanceledand Apple's revoked (refunded) subscriptions only.AVAILABLE_STATUSES:ACTIVE,TRIALING,IN_GRACE_PERIOD,PAST_DUE, the statuses that grant access. A failing renewal keeps access while the store retries and means the customer to be served:IN_GRACE_PERIODon the App Store and Play,PAST_DUEon Stripe (Smart Retries are Stripe's grace period). It loses access once the store has given up (UNPAID) or retries without serving the customer:BILLING_RETRY(an App Store billing retry without a grace period) andON_HOLD(Play's account hold).CancellationDetails: why a subscription stopped renewing, one record for all platforms:initiator(CANCELLATION_INITIATORS:user,system,developer), the store's ownreason,feedback(CANCELLATION_FEEDBACKS),commentandresubscribeIntent(RESUBSCRIBE_INTENTS).
The constants are as const arrays, so they can back database enums directly:
import { PLATFORMS, SUBSCRIPTION_STATUSES } from '@shware/purchase';
import { pgEnum } from 'drizzle-orm/pg-core';
export const platform = pgEnum('platform', PLATFORMS);
export const subscriptionStatus = pgEnum('subscription_status', SUBSCRIPTION_STATUSES);const entitled = (AVAILABLE_STATUSES as readonly SubscriptionStatus[]).includes(sub.status);Product catalogs
Each config is built with a fluent, type-checked chain. Every config takes the product id
namespace, your plans (lowest tier first) and your billingPeriods (shortest first), and infers
its types from them, so no type arguments are needed; a config with no subscriptions passes empty
arrays. Product ids must start with the namespace, plans and periods must be declared ones, and
duplicate products or plan/period pairs fail at compile time and at runtime. Credit metadata
is { credits: number; expiresIn: string } where expiresIn is an
ms duration.
import { defineAppStoreConfig, defineGooglePlayConfig, defineStripeConfig } from '@shware/purchase';
const PLANS = ['STARTER', 'PRO'] as const;
const BILLING_PERIODS = ['MONTHLY', 'YEARLY'] as const;
export const stripe = defineStripeConfig({
namespace: 'com.example.stripe',
plans: PLANS,
billingPeriods: BILLING_PERIODS,
mode: env.STRIPE_SECRET_KEY.startsWith('sk_test_') ? 'test' : 'live',
allowPromotionCodes: false,
// Per billing interval: a repeating coupon only reaches invoices inside its months, so a yearly
// subscription needs a `once` coupon (the next invoice) or no offer at all.
cancellationCoupons: { month: 'retention_30pct_3mo', year: 'retention_10pct_once' },
returnUrl: 'https://example.com',
cancelUrl: 'https://example.com',
successUrl: 'https://example.com/payments/success?session_id={CHECKOUT_SESSION_ID}',
})
// one-time product: no plan or period
.product('com.example.stripe.credit.starter')
.price('price_live_credits', { credits: 10, expiresIn: '1 year' })
.default('price_live_credits', { test: 'price_test_credits' })
// subscription: plan + billing period
.product('com.example.stripe.sub.monthly.starter', 'STARTER', 'MONTHLY')
.price('price_live_starter_v2', { credits: 100, expiresIn: '30 days' })
.price('price_live_starter_v1', { credits: 100, expiresIn: '30 days' }) // grandfathered
.default('price_live_starter_v2', { test: 'price_test_starter' });
export const appStore = defineAppStoreConfig({
namespace: 'com.example.appstore',
plans: PLANS,
billingPeriods: BILLING_PERIODS,
appId: '1234567890',
bundleId: 'com.example.app',
})
.consumable('com.example.appstore.credit.starter', { credits: 10, expiresIn: '365d' })
.subscription('STARTER', 'MONTHLY')
.product('com.example.appstore.sub.monthly.starter.v1', { credits: 100, expiresIn: '30d' })
.default('com.example.appstore.sub.monthly.starter.v1');
export const googlePlay = defineGooglePlayConfig({
namespace: 'com.example.googleplay',
plans: PLANS,
billingPeriods: BILLING_PERIODS,
package: 'com.example.app',
audience: 'api.example.com', // expected audience of the RTDN push OIDC token
})
.subscription('com.example.googleplay.sub.starter')
.basePlan('monthly-v1', 'STARTER', 'MONTHLY', { credits: 100, expiresIn: '30d' })
.basePlan('yearly-v1', 'STARTER', 'YEARLY', { credits: 1200, expiresIn: '365d' })
.default(['monthly-v1', 'yearly-v1'])
.onetime('com.example.googleplay.credit.starter', { credits: 10, expiresIn: '365d' });Lookups:
| Config | Methods |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| StripeConfig | productIds, getPriceId(productId), getProductId(priceId), getPlan, getBillingPeriod, getMode, isSubscription, comparePlans(from, to), getCreditAmount(productId, priceId?), getCreditExpiresAt(productId, priceId?) |
| AppStoreConfig | productIds, getDefaultProductId(plan, billingPeriod), getPlan, getBillingPeriod, getMode, getCreditAmount, getCreditExpiresAt |
| GooglePlayConfig | productIds, getDefaultPlanIds(productId), getPlan(productId, planId), getBillingPeriod(productId, planId), getMode, getCreditAmount(productId, planId?), getCreditExpiresAt(productId, planId?) |
In Stripe test mode the test twin of the default price is what getPriceId returns, and it
resolves to the default price's credits. Non-default prices, products and base plans stay declared
so existing subscribers keep resolving.
Stripe
@shware/purchase/stripe (requires stripe).
Webhooks
Two dispatchers, one per Stripe event format.
Both are typed per event type, run every handler registered for a type, and throw from process
for a type with no handler.
| Handler | Event format | Verify with | Handlers receive |
| ------------------------ | ------------ | ------------------------------------------ | ---------------------------------------- |
| StripeThinEventHandler | thin | stripe.parseEventNotificationAsync(...) | the typed notification (v1.* types) |
| StripeEventHandler | snapshot | stripe.webhooks.constructEventAsync(...) | event.data.object for the event's type |
Which to use: Stripe recommends thin events for new integrations, and so do we. Use snapshot
events when you need the full payload as it was when the event fired, or
data.previous_attributes.
Thin events (recommended)
A thin event only says which object changed (type, id, created, reason, related_object),
so each handler reads current state from Stripe with notification.fetchRelatedObject() (or the
full event with fetchEvent()). The SDK never pre-fetches: some handlers only need
related_object.id. on is keyed by thin event type (v1.customer.subscription.updated, ...),
so fetchRelatedObject() resolves to the right Stripe object. Delivery order carries no meaning:
re-read state instead of applying payloads in order, and dedupe one-off facts (credits, payments,
refunds) with idempotency keys. StripeThinEventNotificationOf<T> types a handler shared across
several types.
Create the event destination with the thin payload style and the v1.* types you handle. A
Cloudflare Workers route with Hono (parseEventNotificationAsync uses Web Crypto there):
import {
METADATA_KEYS,
StripeThinEventHandler,
type StripeThinEventNotificationOf,
mapCancellationDetails,
mapSubscriptionStatus,
mapTime,
} from '@shware/purchase/stripe';
import Stripe from 'stripe';
const stripe = new Stripe(env.STRIPE_SECRET_KEY);
const handler = StripeThinEventHandler.create()
.on('v1.checkout.session.completed', handleCheckoutSessionCompleted)
.on('v1.invoice.paid', handleInvoicePaid)
.on('v1.customer.subscription.created', handleSubscriptionEvent)
.on('v1.customer.subscription.updated', handleSubscriptionEvent)
.on('v1.customer.subscription.deleted', handleSubscriptionEvent);
app.post('/webhooks/stripe', async (c) => {
const notification = await stripe.parseEventNotificationAsync(
await c.req.text(),
c.req.header('stripe-signature') ?? '',
env.STRIPE_WEBHOOK_SECRET
);
await handler.process(notification);
return c.body(null, 204);
});
async function handleSubscriptionEvent(
notification: StripeThinEventNotificationOf<
| 'v1.customer.subscription.created'
| 'v1.customer.subscription.updated'
| 'v1.customer.subscription.deleted'
>
) {
const sub = await notification.fetchRelatedObject(); // Stripe.Subscription, current state
const userId = sub.metadata[METADATA_KEYS.USER_ID];
const productId = sub.metadata[METADATA_KEYS.PRODUCT_ID];
await saveSubscription({
userId,
platform: 'STRIPE',
plan: config.stripe.getPlan(productId),
billingPeriod: config.stripe.getBillingPeriod(productId),
status: mapSubscriptionStatus(sub.status),
startedAt: mapTime(sub.start_date),
cancellationDetails: mapCancellationDetails(sub),
});
}Snapshot events
on is keyed by snapshot event type (customer.subscription.updated, ...) and each handler
receives that event's data.object, typed.
import { StripeEventHandler } from '@shware/purchase/stripe';
export async function processStripeEvent(signature: string, body: string) {
const event = await stripe.webhooks.constructEventAsync(
body,
signature,
env.STRIPE_WEBHOOK_SECRET
);
await StripeEventHandler.create()
.on('invoice.payment_succeeded', handleInvoicePaymentSucceeded) // (invoice: Stripe.Invoice)
.on('customer.subscription.updated', handleSubscription) // (sub: Stripe.Subscription)
.process(event);
}Moving from snapshot to thin events
- Switch the event destination to the thin payload style;
parseEventNotificationAsyncrejects snapshot payloads. - Replace
constructEventAsyncwithparseEventNotificationAsyncandStripeEventHandlerwithStripeThinEventHandler. - Prefix every
ontype withv1.(invoice.paidbecomesv1.invoice.paid). - Handlers receive the notification instead of
event.data.object: callawait notification.fetchRelatedObject(), or usenotification.related_object.idwhen the id is enough. The fetched object is current state, so handlers must not depend on delivery order. - The mappers (
mapSubscriptionStatus,mapInvoice,mapCancellationDetails, ...) take the fetched objects unchanged.
Checkout
createCheckoutSessionSchema(productIds) is a zod/mini schema for the create-checkout request
(productId, quantity default 1, optional cancelUrl / successUrl). METADATA_KEYS names
the metadata fields (user_id, product_id, referral_code, ...) the webhook reads back.
import { zValidator } from '@hono/zod-validator';
import { METADATA_KEYS, createCheckoutSessionSchema } from '@shware/purchase/stripe';
app.post(
'/v1/checkout/sessions',
zValidator('json', createCheckoutSessionSchema(config.stripe.productIds)),
async (c) => {
const { productId, quantity, cancelUrl, successUrl } = c.req.valid('json');
const userId = await getUserId(c);
const session = await stripe.checkout.sessions.create({
mode: config.stripe.getMode(productId),
line_items: [{ price: config.stripe.getPriceId(productId), quantity }],
metadata: { [METADATA_KEYS.USER_ID]: userId, [METADATA_KEYS.PRODUCT_ID]: productId },
allow_promotion_codes: config.stripe.allowPromotionCodes,
success_url: successUrl ?? config.stripe.successUrl,
cancel_url: cancelUrl ?? config.stripe.cancelUrl,
});
return c.json({ id: session.id, url: session.url });
}
);cancellationDetailsSchema validates what your own cancel dialog sends (comment, feedback,
resubscribeIntent).
Mappers
Plain-JSON views of Stripe objects for API responses: mapCheckoutSession, mapLineItem,
mapPrice, mapInvoice, mapPaymentIntent, mapCharge, plus mapSubscription (the
SubscriptionState with the price and dates a row stores), mapSubscriptionStatus,
mapCancellationDetails and mapTime (Unix seconds to ISO string, null passes through).
Browser helpers
These run in the browser and have no runtime dependency on stripe:
price(value, currency)/minorUnits(currency): convert minor units to a display amount, aware of zero-decimal currencies.getBeginCheckoutProperties(productPrice)andgetPurchaseProperties(checkoutSession): GA4-stylebegin_checkout/purchaseevent properties.addPendingCheckout(sessionId),getPendingCheckouts(),removePendingCheckout(sessionId): alocalStoragelist (one-hour TTL) of checkouts to re-confirm if the user closed the tab before returning to the success page.
import {
addPendingCheckout,
getPendingCheckouts,
removePendingCheckout,
} from '@shware/purchase/stripe';
// before redirecting to Stripe
addPendingCheckout(session.id);
// on app start
for (const sessionId of getPendingCheckouts()) {
await confirmCheckoutSession(sessionId);
removePendingCheckout(sessionId);
}App Store
@shware/purchase/app-store (requires @apple/app-store-server-library).
Decode and verify the notification with Apple's SignedDataVerifier, then dispatch the decoded
event by notificationType with AppStoreNotificationHandler. Branch on subtype inside each
handler. Whatever the notification says, write the row from the subscription's current state: read
it with Get All Subscription Statuses and map it with mapSubscription.
import {
type Environment,
NotificationTypeV2,
type SignedDataVerifier,
} from '@apple/app-store-server-library';
import {
type AppStoreNotificationEvent,
AppStoreNotificationHandler,
mapCancellationDetails,
mapGraceUntil,
mapSubscription,
} from '@shware/purchase/app-store';
export async function processAppStoreNotification(verifier: SignedDataVerifier, body: string) {
const payload = await verifier.verifyAndDecodeNotification(body);
const { signedTransactionInfo, signedRenewalInfo } = payload.data ?? {};
const transaction = signedTransactionInfo
? await verifier.verifyAndDecodeTransaction(signedTransactionInfo)
: null;
const renewalInfo = signedRenewalInfo
? await verifier.verifyAndDecodeRenewalInfo(signedRenewalInfo)
: null;
await AppStoreNotificationHandler.create()
.on(NotificationTypeV2.SUBSCRIBED, handleSubscribed)
.on(NotificationTypeV2.DID_RENEW, handleRenewed)
.on(NotificationTypeV2.DID_CHANGE_RENEWAL_STATUS, handleRenewalStatusChanged)
.on(NotificationTypeV2.EXPIRED, handleExpired)
.on(NotificationTypeV2.REFUND, handleRefunded)
.process({ payload, transaction, renewalInfo });
}
async function handleRenewalStatusChanged({ payload, renewalInfo }: AppStoreNotificationEvent) {
const cancellationDetails = mapCancellationDetails({
notificationType: payload.notificationType!,
subtype: payload.subtype,
expirationIntent: renewalInfo?.expirationIntent,
});
// null when auto-renew was turned back on
}
async function readSubscription(environment: Environment, originalTransactionId: string) {
const client = await getClient(environment); // your AppStoreServerAPIClient
const verifier = await getVerifier(environment);
const response = await client.getAllSubscriptionStatuses(originalTransactionId);
const last = response.data
?.flatMap((group) => group.lastTransactions ?? [])
.find((item) => item.originalTransactionId === originalTransactionId);
if (!last?.signedTransactionInfo || !last.signedRenewalInfo) return null;
const transaction = await verifier.verifyAndDecodeTransaction(last.signedTransactionInfo);
const renewalInfo = await verifier.verifyAndDecodeRenewalInfo(last.signedRenewalInfo);
const state = mapSubscription({ status: last.status, transaction, renewalInfo });
const graceUntil = mapGraceUntil({ ...state, ...renewalInfo });
// save state.status, state.autoRenewEnabled, state.trialStart, state.trialEnd ...
return { ...state, transaction, renewalInfo, graceUntil };
}mapSubscription reads Apple's status as ACTIVE, EXPIRED, BILLING_RETRY,
IN_GRACE_PERIOD or CANCELED (revoked: refunded, or taken back from Family Sharing). Auto-renew
turned off is not a status: the subscription stays ACTIVE with autoRenewEnabled: false until it
expires. An active subscription whose latest transaction is a free trial (offerDiscountType:
FREE_TRIAL) is TRIALING, with trialStart / trialEnd from the transaction's purchaseDate /
expiresDate; once a paid transaction follows, the dates are null (Apple keeps none), so keep the
ones you stored if you want them past the trial. The trial transaction has price: 0; grant nothing
for it, the first charge arrives as its own DID_RENEW.
Map transaction.productId to your plan with config.appStore.getPlan(productId) and
getBillingPeriod(productId), and grant credits with getCreditAmount / getCreditExpiresAt.
Google Play
@shware/purchase/google-play (no peer dependency).
It provides the RTDN payload types (PubsubMessage, DeveloperNotification,
SubscriptionNotification, OneTimeProductNotification, VoidedPurchaseNotification,
TestNotification), the numeric enums (SubscriptionNotificationType,
OneTimeProductNotificationType, VoidedPurchaseProductType, VoidedPurchaseRefundType), and
mappers for the purchases.subscriptionsv2 resource. Verify the Pub/Sub push token (its audience
is config.googlePlay.audience) and fetch the purchase with the Android Publisher API yourself.
import {
type DeveloperNotification,
type PubsubMessage,
mapCanceledAt,
mapCancellationDetails,
mapSubscription,
} from '@shware/purchase/google-play';
export async function processGooglePlayNotification(authorization: string, body: string) {
await verifyIdToken(authorization.replace('Bearer ', '')); // your OIDC check
const { message } = JSON.parse(body) as { message: PubsubMessage };
const notification = JSON.parse(
Buffer.from(message.data, 'base64').toString('utf-8')
) as Partial<DeveloperNotification>;
if (notification.subscriptionNotification) {
const { purchaseToken } = notification.subscriptionNotification;
const { data } = await publisher.purchases.subscriptionsv2.get({
packageName: config.googlePlay.package,
token: purchaseToken,
});
const { status, autoRenewEnabled, trialStart, trialEnd } = mapSubscription(data);
const cancellationDetails = mapCancellationDetails(data.canceledStateContext);
const canceledAt = mapCanceledAt(data.canceledStateContext);
// save status, autoRenewEnabled, trialStart, trialEnd, cancellationDetails, canceledAt ...
}
}mapSubscription reads the whole purchases.subscriptionsv2 resource, not just
subscriptionState, because Play's states do not line up with the unified ones one to one:
SUBSCRIPTION_STATE_CANCELEDmeans auto-renew is off while the paid period still runs, so it isACTIVE(orTRIALING) withautoRenewEnabled: false, like Stripe'scancel_at_period_endand Apple'sautoRenewStatusoff;canceledStateContextcarries the cancellation (mapCancellationDetails,mapCanceledAt). Once every line item'sexpiryTimehas passed it isEXPIRED. Mapping it toCANCELEDwould drop the customer's entitlement the moment they turn auto-renew off:CANCELEDis outsideAVAILABLE_STATUSES.- A purchase with
canceledStateContext.replacementCancellationisREPLACED: the linked purchase that replaced it is the one the customer has. - A line item in its free-trial offer phase (
offerPhase.freeTrial) makes an active subscriptionTRIALING, withtrialStartthe purchase'sstartTimeandtrialEndthe item'sexpiryTime. The trial's order is for $0; grant nothing for it, the first charge comes with its own order. autoRenewEnabledis whether any line item still renews; a deferred replacement, whose items carry no flag, renews into its new plan unless Play reports the subscription cancelled.
Resolve the plan from a line item with config.googlePlay.getPlan(productId, basePlanId) and
getBillingPeriod(productId, basePlanId). GOOGLE_PLAY_SUBSCRIPTION_STATES lists the raw
SUBSCRIPTION_STATE_* values.
