@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
| Version | Published | Integrity |
|---|---|---|
| 1.8.0 latest | 2026-10-01 | sha512-pEcw+QzUeVdQ36u… |
| 1.7.0 | 2026-09-30 | sha512-VcKRXUnyhEq7h0a… |
| 1.6.1 | 2026-07-25 | sha512-eeA5B8bUwDl1d21… |
| 1.6.0 | 2026-07-24 | sha512-SBnM7vk75BSyQg6… |
| 1.5.1 | 2026-07-24 | sha512-hj9eVxViHHMEUPi… |
| 1.5.0 | 2026-07-24 | sha512-0yaIAsXr5prwA0V… |
| 1.4.0 | 2026-07-18 | sha512-DMBPkNW2x9+cuzs… |
| 1.3.0 | 2026-07-18 | sha512-SH7LbaifeqqWXCj… |
| 1.2.0 | 2026-07-17 | sha512-HjwZGtQNatOQazc… |
| 1.1.0 | 2026-07-17 | sha512-f/zCmh5z3e/QXCB… |
| 1.0.0 | 2026-03-28 | sha512-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
| Export | From | Use |
|---|---|---|
<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> | /pages | the whole pricing page |
<PlusUpsellProvider>, usePlusUpsell(), handleUpgradeRequired(), isUpgradeRequired(), triggerPlusUpsell(), subscribePlusUpsell() | /upsell | the 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
- No
accessToken→ "Not authenticated" on checkout. The single most common wiring bug in this pearl. - Tier mapping is generous.
plus,family,premium,pro,basic,enterpriseall resolve toplus; anything unknown resolves tofree. - Gates without a
/plusroute = users hit a wall with no door. - Client gates are UX, not security. The server must enforce the same limits; the client gate only decides what to show.
- Don't invent per-app prices in the UI. Read them from the tiers endpoint — hard-coded prices have gone stale and misled users before.
Publishing
npm run build && npm publish --access public