npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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's mapSubscription returns, { status, autoRenewEnabled, trialStart, trialEnd }, so the same situation reads the same everywhere: a customer who turned auto-renew off is ACTIVE with autoRenewEnabled: false until the period runs out (Stripe's cancel_at_period_end, Apple's autoRenewStatus off, Play's SUBSCRIPTION_STATE_CANCELED), and a free trial is TRIALING with its dates on all three. CANCELED is Stripe's canceled and 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_PERIOD on the App Store and Play, PAST_DUE on 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) and ON_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 own reason, feedback (CANCELLATION_FEEDBACKS), comment and resubscribeIntent (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; parseEventNotificationAsync rejects snapshot payloads.
  • Replace constructEventAsync with parseEventNotificationAsync and StripeEventHandler with StripeThinEventHandler.
  • Prefix every on type with v1. (invoice.paid becomes v1.invoice.paid).
  • Handlers receive the notification instead of event.data.object: call await notification.fetchRelatedObject(), or use notification.related_object.id when 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) and getPurchaseProperties(checkoutSession): GA4-style begin_checkout / purchase event properties.
  • addPendingCheckout(sessionId), getPendingCheckouts(), removePendingCheckout(sessionId): a localStorage list (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_CANCELED means auto-renew is off while the paid period still runs, so it is ACTIVE (or TRIALING) with autoRenewEnabled: false, like Stripe's cancel_at_period_end and Apple's autoRenewStatus off; canceledStateContext carries the cancellation (mapCancellationDetails, mapCanceledAt). Once every line item's expiryTime has passed it is EXPIRED. Mapping it to CANCELED would drop the customer's entitlement the moment they turn auto-renew off: CANCELED is outside AVAILABLE_STATUSES.
  • A purchase with canceledStateContext.replacementCancellation is REPLACED: 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 subscription TRIALING, with trialStart the purchase's startTime and trialEnd the item's expiryTime. The trial's order is for $0; grant nothing for it, the first charge comes with its own order.
  • autoRenewEnabled is 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.