PPM

@pixygon/subscription

Pixygon+ subscription management — shared across all Pixygon apps

Install

# .npmrc
@pixygon:registry=https://ppm.pixygon.io/

npm install @pixygon/subscription@1.8.0

Versions

VersionPublishedIntegrity
1.8.0 latest2026-10-01sha512-pEcw+QzUeVdQ36u…
1.7.0 2026-09-30sha512-VcKRXUnyhEq7h0a…
1.6.1 2026-07-25sha512-eeA5B8bUwDl1d21…
1.6.0 2026-07-24sha512-SBnM7vk75BSyQg6…
1.5.1 2026-07-24sha512-hj9eVxViHHMEUPi…
1.5.0 2026-07-24sha512-0yaIAsXr5prwA0V…
1.4.0 2026-07-18sha512-DMBPkNW2x9+cuzs…
1.3.0 2026-07-18sha512-SH7LbaifeqqWXCj…
1.2.0 2026-07-17sha512-HjwZGtQNatOQazc…
1.1.0 2026-07-17sha512-f/zCmh5z3e/QXCB…
1.0.0 2026-03-28sha512-DYbIVdB1WECdGvG…

@pixygon/subscription

Pixygon+ for every app — tier detection, per-app feature gates, hosted Stripe checkout, billing portal, and the upsell growth loop. 7 repos depend on it (Tastebud, Kartograf, CoreFeed, SoloBattle, pixygon.io, pixiel.ai, lonnlyst.no).

One Pixygon+ subscription unlocks the tier everywhere; each app decides what "Plus" means for it via its own AppFeatureConfig.

⚠ This is money code. Static checks are not enough — see "Verify".


Retrofitting it into an existing app (≈20 minutes)

1. Install

npm i @pixygon/subscription

@pixygon/auth is a peer dependency — the provider needs its access token and the user's tier. Install and mount auth first.

2. Define what Plus means here

import type { AppFeatureConfig } from '@pixygon/subscription'

const APP_FEATURES: AppFeatureConfig = {
  appId: 'tastebud',          // lowercase app slug (also picks the /plus page palette)
  features: {
    // boolean = on/off · number = limit (-1 unlimited, 0 disabled)
    aiRecommendations: { free: true,  plus: true },
    aiWrapped:         { free: false, plus: true },
    customPins:        { free: 3,     plus: -1   },
  },
}

Gate the things that actually cost money (unmetered AI calls, storage), not the top-of-funnel. If the server grants a free daily allowance, mirror it with free: true — a client that hard-gates at zero while the server allows three contradicts itself and kills the funnel.

family is deprecated (the family plan was removed); it is still accepted for back-compat and resolves to the same as plus.

3. Mount the provider — inside AuthProvider

// src/providers/PixygonPlusProvider.tsx  — the estate pattern
import { SubscriptionProvider } from '@pixygon/subscription'
import { useToken, useUser } from '@pixygon/auth'

function SubscriptionBridge({ children }: { children: ReactNode }) {
  const { accessToken } = useToken()
  const { subscriptionTier } = useUser()
  return (
    <SubscriptionProvider
      config={{ features: APP_FEATURES }}
      accessToken={accessToken}
      userTier={subscriptionTier || undefined}
    >
      {children}
    </SubscriptionProvider>
  )
}

⚠ accessToken is not optional in practice. Without it the provider never fetches the real subscription and startCheckout() throws "Not authenticated" — that exact bug shipped in five apps at once. userTier is the fast path so gates don't flicker to free while the API responds.

4. Gate features

import { useSubscription, FeatureGate, PlusGate, UpgradePrompt, SubscriptionBadge } from '@pixygon/subscription'

const { tier, isPlus, canUse, getLimit, startCheckout, openPortal, cancelSubscription } = useSubscription()

if (!canUse('aiWrapped')) return <UpgradePrompt />
const pinLimit = getLimit('customPins')      // 3 free, -1 plus

<FeatureGate feature="aiWrapped" fallback={<UpgradePrompt />}>
  <WrappedReport />
</FeatureGate>

5. Give people a way to buy

import { PixygonPlusPage } from '@pixygon/subscription/pages'

<Route path="/plus" element={<PixygonPlusPage currentApp="tastebud" />} />

The page adopts the host app's palette from currentApp, lists the live tiers from GET /v1/stripe/pixygon-plus/tiers, and calls startCheckout → hosted Stripe Checkout (redirect to response.url). Gates without a /plus route is a dead paywall.

6. Wire the upsell loop (optional but this is the growth part)

import { PlusUpsellProvider, usePlusUpsell, handleUpgradeRequired } from '@pixygon/subscription/upsell'

<PlusUpsellProvider
  currentApp="tastebud"
  onEvent={(name, props) => track(name, props)}   // → @pixygon/analytics
  onUpgrade={() => navigate('/plus')}
>
  <App />
</PlusUpsellProvider>

// data layer: turn a server 402 into the modal
if (handleUpgradeRequired(error)) return
// component: trigger it directly
const { showUpsell } = usePlusUpsell()
showUpsell({ feature: 'aiRecommendations', freeLimit: 3, used: 3 })

7. Verify it worked

node node_modules/@pixygon/subscription/verify.mjs
# or:
pearl verify subscription

Checks: package + @pixygon/auth installed, <SubscriptionProvider> mounted, accessToken prop wired, a real AppFeatureConfig (non-placeholder appId, non-empty features), an API base that isn't localhost, something actually gated, and a purchase path present. Exit 0 pass, 1 fail.

Add a live probe of the public pricing endpoint (no auth, no money):

node node_modules/@pixygon/subscription/verify.mjs --url=https://api.pixygon.com/v1

What verify cannot prove — and this matters here: that a purchase completes. It never touches Stripe. Before trusting a subscription change, run a real test checkout end to end (checkout → webhook → tier flips → gate opens) and confirm the app's Stripe env vars/price ids exist for this app. A solo "looks fine" sweep on money code has been overturned before.


API

ExportFromUse
<SubscriptionProvider config accessToken userTier>.the mount
useSubscription().tier, isPlus, subscription, isLoading, error, canUse, getLimit, getFeature, startCheckout, openPortal, cancelSubscription, reactivateSubscription, refetch
<FeatureGate feature fallback> / <PlusGate>.declarative gates
<UpgradePrompt> / <SubscriptionBadge>.upsell UI bits
<PixygonPlusPage currentApp>/pagesthe whole pricing page
<PlusUpsellProvider>, usePlusUpsell(), handleUpgradeRequired(), isUpgradeRequired(), triggerPlusUpsell(), subscribePlusUpsell()/upsellthe 402 → modal → /plus loop
SubscriptionApi.imperative client (/stripe/pixygon-plus/*)

Endpoints it calls

GET  /v1/stripe/pixygon-plus/tiers          (public)
GET  /v1/stripe/pixygon-plus/subscription   (bearer)
POST /v1/stripe/pixygon-plus/checkout       (bearer) → { url } hosted checkout
POST /v1/stripe/pixygon-plus/portal|cancel|reactivate

Default base https://api.pixygon.com/v1; override with config.apiUrl.

Gotchas

Publishing

npm run build && npm publish --access public

All packages · packument JSON