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

@onesub/server

v0.27.5

Published

Server-side receipt validation middleware for react-native-iap. Apple StoreKit 2 + Google Play Billing. One line.

Downloads

1,129

Readme

@onesub/server

Express middleware for Apple StoreKit 2 + Google Play Billing receipt validation, webhooks, and subscription/purchase storage. One line to mount.

npm install @onesub/server

Requirements

  • Node.js >= 20
  • Express as a peer dependency — ^4.17.0 || ^5.0.0:
    npm install @onesub/server express
  • Optional peer dependencies (install only what you use):
    npm install ioredis        # Redis stores, cache, webhook idempotency
    npm install bullmq         # Durable webhook queue with dead-letter list
    npm install pg             # Postgres stores
    npm install @opentelemetry/api   # Distributed tracing

Quick start

import express from 'express';
import { createOneSubMiddleware, PostgresSubscriptionStore, PostgresPurchaseStore } from '@onesub/server';

const app = express();

app.use(createOneSubMiddleware({
  apple: {
    bundleId: 'com.yourapp.id',
    sharedSecret: process.env.APPLE_SHARED_SECRET,
    // Optional — required only for the App Store Server API features below
    // (status fetch fallback, consumption response).
    keyId: process.env.APPLE_KEY_ID,
    issuerId: process.env.APPLE_ISSUER_ID,
    privateKey: process.env.APPLE_PRIVATE_KEY,
  },
  google: { packageName: 'com.yourapp.id', serviceAccountKey: process.env.GOOGLE_SERVICE_ACCOUNT_KEY },
  database: { url: process.env.DATABASE_URL },
  store:         new PostgresSubscriptionStore(process.env.DATABASE_URL),
  purchaseStore: new PostgresPurchaseStore(process.env.DATABASE_URL),
  // Optional:
  adminSecret: process.env.ADMIN_SECRET,   // enables admin + metrics routes
  logger: require('pino')(),               // any { info, warn, error } logger
  refundPolicy: 'immediate',               // 'immediate' (default) | 'until_expiry'
  metricsCacheTtlSeconds: 30,              // 30 (default) | 0 to disable
}));

app.listen(4100);

Serving multiple apps

Keep the top-level apple/google fields for a single app. To validate several bundle IDs and package names from one process, configure apps and send appId in validation requests:

app.use(createOneSubMiddleware({
  database: { url: process.env.DATABASE_URL },
  apps: [
    { id: 'coffee', apple: { bundleId: 'gg.pryzm.coffee' } },
    {
      id: 'penguinrun',
      apple: { bundleId: 'gg.pryzm.penguinrun' },
      google: {
        packageName: 'gg.pryzm.penguinrun',
        serviceAccountKey: process.env.PENGUINRUN_GOOGLE_SERVICE_ACCOUNT_KEY,
      },
    },
  ],
  defaultAppId: 'coffee',
}));

Apple receipts carry their bundle ID and can be resolved automatically. Google purchase tokens do not carry a package name, so requests for a non-default Google app must include appId. Unknown explicit app IDs fail closed and are never validated with another app's credentials.

Endpoints

| Route | Purpose | |------|---------| | POST /onesub/validate | Verify Apple/Google subscription receipt | | GET /onesub/status?userId= | Check subscription state | | POST /onesub/webhook/apple | App Store Server Notifications V2 | | POST /onesub/webhook/google | Google Play RTDN (Pub/Sub push) | | POST /onesub/purchase/validate | Verify one-time purchase (consumable / non-consumable) | | GET /onesub/purchase/status?userId= | List user's one-time purchases | | DELETE /onesub/purchase/admin/:userId/:productId | Wipe a non-consumable (requires adminSecret) | | POST /onesub/purchase/admin/grant | Manually grant a purchase (requires adminSecret) | | POST /onesub/purchase/admin/transfer | Reassign a transactionId to a new userId (requires adminSecret) | | GET /onesub/admin/subscriptions | Filter and paginate subscriptions (requires adminSecret) | | GET /onesub/admin/subscriptions/:transactionId | Get subscription detail (requires adminSecret) | | GET /onesub/admin/customers/:userId | Get a customer profile (requires adminSecret) | | POST /onesub/admin/sync-apple/:originalTransactionId | Refresh a subscription from Apple (requires adminSecret) | | GET /onesub/admin/test-overrides | List sandbox-only entitlement overrides (requires adminSecret) | | PUT /onesub/admin/test-overrides/:userId | Force an entitlement verdict for one user — body { "entitled": false }. Honoured only for Sandbox receipts, so production customers are unaffected. Exists because Apple cannot cancel a sandbox subscription bought with a real Apple Account, which otherwise locks a tester out of the purchase flow. Process-local and non-persistent | | DELETE /onesub/admin/test-overrides/:userId | Clear that user's override (requires adminSecret) | | GET /onesub/admin/webhook-deadletters | List failed webhook jobs in DLQ (requires adminSecret + BullMQ) | | POST /onesub/admin/webhook-replay/:id | Replay a dead-letter job (requires adminSecret + BullMQ) | | GET /onesub/entitlement?userId=&id= | Evaluate one configured entitlement | | GET /onesub/entitlements?userId= | Evaluate all configured entitlements | | GET /onesub/metrics/active | Current entitled counts (requires adminSecret) | | GET /onesub/metrics/started | Subscriptions started in a range (requires adminSecret) | | GET /onesub/metrics/expired | Subscriptions ended in a range (requires adminSecret) | | GET /onesub/metrics/purchases/started | Non-consumables started in a range (requires adminSecret) | | POST /onesub/apple/offer-signature | Sign an Apple promotional offer when offer credentials are configured | | GET /openapi.json | OpenAPI 3.1 spec (opt-in via openapiHandler()) |

Lifecycle states (0.4.0+)

SubscriptionInfo.status carries the full lifecycle. The status route's active: boolean is computed as (active || grace_period) && expiresAt > now — but the raw status lets you render accurate UX:

| Status | active | UX hint | |--------|--------|---------| | active | ✅ | normal | | grace_period | ✅ | "결제 정보 확인 필요 (계속 사용 가능)" | | on_hold | ❌ | "결제 정보를 업데이트하세요" | | paused | ❌ | "재개 예정: {autoResumeTime}" | | expired / canceled | ❌ | re-purchase or restore |

See @onesub/shared README for the full mapping.

Refund policy (0.8.0+)

refundPolicy: 'immediate' | 'until_expiry'   // default 'immediate'
  • 'immediate' — subscription refunds (Apple REFUND/REVOKE, Google voided productType=1) flip status to canceled right away. Strict, fraud-resistant.
  • 'until_expiry' — keep status/expiresAt untouched, only flip willRenew = false. User keeps entitlement until the original expiry. Better UX for goodwill refunds.

IAP refunds (consumable / non-consumable) are always immediate regardless of policy — they have no expiry concept.

Optional Apple App Store Server API features (0.8.0+)

Set apple.keyId / apple.issuerId / apple.privateKey (PKCS8 ES256 from App Store Connect → Users and Access → Keys) to unlock:

Status API fallback (automatic)

If a webhook arrives for an originalTransactionId the store doesn't know (server downtime, queue truncation, fresh install), the webhook handler calls GET /inApps/v1/subscriptions/{originalTransactionId} to fetch canonical state from Apple and saves a record under a placeholder userId. Subsequent /onesub/validate from the host can claim ownership.

You can also call it directly:

import { fetchAppleSubscriptionStatus } from '@onesub/server';

const sub = await fetchAppleSubscriptionStatus(originalTxId, config.apple, { sandbox: false });
// sub: SubscriptionInfo | null  — null on missing creds / 404 / network failure

Transaction History API client

fetchAppleTransactionHistory() follows Apple's revision pagination and returns verified transaction records. It is a low-level recovery/migration primitive; the host decides how to reconcile returned records with its stores.

import { fetchAppleTransactionHistory } from '@onesub/server';

const transactions = await fetchAppleTransactionHistory(originalTxId, config.apple, {
  sandbox: false,
});

CONSUMPTION_REQUEST response hook

When Apple sends a CONSUMPTION_REQUEST notification (consumable refund review), without a hook Apple has no usage signal and tends to grant the refund. Provide a hook to PUT consumption info back to /inApps/v1/transactions/consumption/{txId}:

apple: {
  // ...
  consumptionInfoProvider: async (ctx) => ({
    customerConsented: true,                  // required; false makes Apple ignore the response
    consumptionStatus: 3,                     // 0=undeclared, 1=not consumed, 2=partial, 3=full
    deliveryStatus: 1,                        // 0=undeclared, 1=delivered & working, 2=quality issue, ...
    refundPreference: 2,                      // 0=undeclared, 1=grant, 2=decline, 3=no preference
    // see AppleConsumptionRequest for the full set of optional fields
  }),
}

Fire-and-forget; failures are logged but don't block the webhook 200.

Promotional offer signing

Configure the separate subscription-offer key as apple.offerKeyId and apple.offerPrivateKey to mount POST /onesub/apple/offer-signature. If adminSecret is configured, callers must send the same value in X-Onesub-Offer-Secret. The endpoint returns StoreKit's keyId, nonce, timestamp, and signature; never expose the private key to the client.

Optional Google hooks (0.8.0+)

google: {
  // ...
  // Called when SUBSCRIPTION_PRICE_CHANGE_CONFIRMED (8) arrives — user agreed
  // to a price change; new price applies on next renewal. Useful for analytics.
  onPriceChangeConfirmed: async (ctx) => {
    await analytics.track('price_change_confirmed', ctx);
  },
}

Multi-instance deployments (0.12.0+)

By default every option uses in-process memory — zero config, zero infra. For multi-node clusters, swap in the Redis-backed equivalents:

import Redis from 'ioredis';
import {
  createOneSubMiddleware,
  RedisSubscriptionStore,
  RedisPurchaseStore,
  RedisCacheAdapter,
  RedisWebhookEventStore,
} from '@onesub/server';

const redis = new Redis(process.env.REDIS_URL!);

app.use(createOneSubMiddleware({
  // ...apple / google / database config...
  store:              new RedisSubscriptionStore(redis),
  purchaseStore:      new RedisPurchaseStore(redis),
  cache:              new RedisCacheAdapter(redis),      // share Apple JWT + Google OAuth tokens across nodes
  webhookEventStore:  new RedisWebhookEventStore(redis), // atomic SET NX dedup — one node handles each event
}));

| Class | Replaces | What it does | |-------|----------|--------------| | RedisSubscriptionStore | InMemorySubscriptionStore | Sorted-set index, newest-first pagination | | RedisPurchaseStore | InMemoryPurchaseStore | Cross-node purchase ownership | | RedisCacheAdapter | InMemoryCacheAdapter | Shared Apple JWT / Google OAuth token cache | | RedisWebhookEventStore | InMemoryWebhookEventStore | Atomic SET NX dedup (no get→set race) |

Webhook queue with durable retries

Replace the default in-process queue with BullMQ for decoupled retries and a dead-letter list:

import { BullMQWebhookQueue } from '@onesub/server';
import Redis from 'ioredis';

const connection = new Redis(process.env.REDIS_URL!, { maxRetriesPerRequest: null });
const webhookQueue = new BullMQWebhookQueue({ connection });

app.use(createOneSubMiddleware({
  // ...
  webhookQueue,
  adminSecret: process.env.ADMIN_SECRET, // required for DLQ endpoints
}));

The webhook route 200s immediately once the job is enqueued. The BullMQ worker retries up to 5× (exponential backoff, 1 s base) before moving the job to the dead-letter list. Inspect and replay via the admin endpoints above.

Cache adapter (0.12.0+)

The cache option controls how Apple JWT assertions and Google OAuth access tokens are cached between requests. Default is InMemoryCacheAdapter (process-local). Shared Redis cache prevents every cluster node from minting its own token:

import { InMemoryCacheAdapter, RedisCacheAdapter } from '@onesub/server';

// single instance (default — no config needed)
cache: new InMemoryCacheAdapter()

// multi-instance
cache: new RedisCacheAdapter(redis)
cache: new RedisCacheAdapter(redis, 'myapp:cache:') // custom key prefix

You can also pass any object implementing CacheAdapter:

export interface CacheAdapter {
  get<T>(key: string): Promise<T | null>;
  set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
  del(key: string): Promise<void>;
}

OpenAPI (0.12.0+)

The full OpenAPI 3.1 document is exported as ONESUB_OPENAPI. Self-host it on your existing Express app:

import { openapiHandler } from '@onesub/server';

app.get('/openapi.json', openapiHandler());

Or import the spec directly for client generation:

import { ONESUB_OPENAPI } from '@onesub/server';
// use with openapi-typescript, swagger-ui-express, etc.

OpenTelemetry tracing (0.12.0+)

Install @opentelemetry/api alongside an OTel SDK and spans appear automatically for receipt validation and webhook processing. The helper is zero-overhead when the package is absent — no require error, no performance hit.

You can wrap your own code in the same tracer:

import { withSpan } from '@onesub/server';

const result = await withSpan('my-operation', { 'user.id': userId }, async () => {
  return await doWork();
});

Schema

Canonical Postgres DDL shipped at sql/schema.sql. Apply with psql -f or let store.initSchema() run it for you on startup.

store.initSchema() is safe to call on every boot — all DDL is IF NOT EXISTS. New columns added in later releases (e.g. linked_purchase_token, auto_resume_time) ship with ALTER TABLE IF NOT EXISTS so existing installs auto-backfill on the next startup.

Security

  • Apple JWS signature verified end-to-end against Apple Root CA G3 (as of 0.6.0)
  • Google RTDN: Authorization: Bearer JWT verified against Google JWKS when pushAudience is configured. Without it the request cannot be attributed to Google and is refused with 401 under NODE_ENV=production, unless google.allowUnauthenticatedWebhook opts in
  • transactionId ownership enforced — same receipt can't be reused across users (0.5.0+)
  • zod input validation + 50 KB body cap
  • Full write-up: docs/SECURITY.md
  • Error troubleshooting: docs/RECEIPT-ERRORS.md

Links

MIT © onesub contributors.