The Problem
You use react-native-iap to handle purchases. But then you need a server to:
- Verify Apple StoreKit 2 receipts (JWS signature validation)
- Verify Google Play receipts (Play Developer API v3)
- Handle webhooks (renewals, cancellations, refunds)
- Track subscription state in a database
- Track one-time purchases (consumables + non-consumables)
- Expose "is this user subscribed?" and "what did this user buy?" endpoints
That's 2-3 weeks of work. Or one line:
app.use(createOneSubMiddleware(config));
How It Works
react-native-iap (client) @onesub/server (your backend)
┌─────────────────────────────────┐
Subscriptions: │ │
requestSubscription() ──receipt───▶ │ POST /onesub/validate │
fetch('/onesub/status') ──────────▶ │ GET /onesub/status │
│ │
One-time purchases: │ │
requestPurchase() ────receipt───▶ │ POST /onesub/purchase/validate │
fetch('/onesub/purchase/status') ──▶│ GET /onesub/purchase/status │
│ │
Webhooks (auto): │ POST /onesub/webhook/apple │
│ POST /onesub/webhook/google │
└─────────────────────────────────┘
Quick Start
1. Install
npm install @onesub/server
2. Add to your Express app
import { createOneSubMiddleware, PostgresSubscriptionStore, PostgresPurchaseStore } from '@onesub/server';
app.use(createOneSubMiddleware({
apple: {
bundleId: 'com.yourapp.id',
sharedSecret: process.env.APPLE_SHARED_SECRET,
},
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),
}));
3. Check subscription from your app
const res = await fetch('https://api.yourapp.com/onesub/status?userId=user123');
const { active } = await res.json();
// active: true → subscribed, false → not subscribed
Done. Apple/Google receipt validation, webhooks, and subscription tracking — all handled.
What You Get
Subscriptions (auto-renewable)
| Endpoint | What it does |
|---|---|
POST /onesub/validate |
Verify receipt, save subscription |
GET /onesub/status?userId= |
Check if user has active subscription |
POST /onesub/webhook/apple |
Handle App Store Server Notifications V2 |
POST /onesub/webhook/google |
Handle Google Real-Time Developer Notifications |
Lifecycle states
SubscriptionInfo.status carries the full lifecycle. The /onesub/status route's active: boolean collapses it for simple gating; the raw status lets you render accurate UX.
stateDiagram-v2
[*] --> active : 구매 / SUBSCRIBED / PURCHASED
active --> grace_period : Apple DID_FAIL_TO_RENEW + GRACE_PERIOD\nGoogle IN_GRACE_PERIOD
grace_period --> active : DID_RENEW / RENEWED
grace_period --> on_hold : Apple GRACE_PERIOD_EXPIRED\nGoogle ON_HOLD
on_hold --> active : DID_RENEW / RECOVERED
active --> paused : Google SUBSCRIPTION_PAUSED
paused --> active : SUBSCRIPTION_RESTARTED
active --> canceled : REFUND / REVOKE
grace_period --> canceled : REFUND
on_hold --> canceled : REFUND
active --> expired : 자연 만료 / EXPIRED
| State | active |
When | Host UX hint |
|---|---|---|---|
active |
✅ | paid period | normal |
grace_period |
✅ | payment failed but store still grants access | "결제 정보 확인 필요 (계속 사용 가능)" |
on_hold |
❌ | grace ended, billing retry continues | "결제 정보를 업데이트하세요" |
paused |
❌ | user-voluntary pause (Google only) | "재개 예정: {autoResumeTime}" |
expired |
❌ | natural end without renewal | re-purchase |
canceled |
❌ | refunded or revoked | re-purchase / restore |
active is computed as (active || grace_period) && expiresAt > now — the expiresAt check is a backstop for missed EXPIRED webhooks and for the 'until_expiry' refund policy.
One-time Purchases (consumable + non-consumable)
| Endpoint | What it does |
|---|---|
POST /onesub/purchase/validate |
Verify receipt, save purchase. Response includes action: 'new' | 'restored' so the client can distinguish a first-time purchase from an idempotent replay or reinstall-triggered reassignment. |
GET /onesub/purchase/status?userId= |
List user's purchases |
Admin (opt-in — requires config.adminSecret)
Mounted only when config.adminSecret is set. All requests must include the X-Admin-Secret header.
Purchase admin (testing + migration):
| Endpoint | What it does |
|---|---|
DELETE /onesub/purchase/admin/:userId/:productId |
Wipe a non-consumable so the user can re-test the purchase flow |
POST /onesub/purchase/admin/grant |
Manually insert a purchase record (bypasses store verification) |
POST /onesub/purchase/admin/transfer |
Reassign a transactionId to a new userId (legitimate device/account migration) |
Dashboard / operational:
| Endpoint | What it does |
|---|---|
GET /onesub/admin/subscriptions?userId=&status=&productId=&platform=&limit=&offset= |
Filtered + paginated subscription list (max 200 per page) |
GET /onesub/admin/subscriptions/:transactionId |
Single subscription record by originalTransactionId |
GET /onesub/admin/customers/:userId |
Full per-user profile: subscriptions + purchases + entitlements (when configured) |
GET /onesub/admin/webhook-deadletters |
List failed webhook jobs (requires BullMQ queue) |
POST /onesub/admin/webhook-replay/:id |
Replay a failed webhook job (requires BullMQ queue) |
POST /onesub/admin/sync-apple/:originalTransactionId |
Refresh one subscription from the Apple Status API (requires App Store Server API credentials) |
GET /onesub/admin/test-overrides |
List active sandbox entitlement overrides |
PUT /onesub/admin/test-overrides/:userId |
Force an entitlement verdict for one user — body { "entitled": false } |
DELETE /onesub/admin/test-overrides/:userId |
Clear that user's override |
Sandbox entitlement overrides
Apple offers no way to cancel a sandbox subscription bought with a real Apple
Account through TestFlight — "Clear Purchase History" in App Store Connect only
covers sandbox tester accounts. A developer who subscribes once to check the
paywall therefore stays entitled and cannot re-run the purchase flow. Deleting
server records does not help either: /onesub/validate re-derives entitlement
from Apple on every call, so a local edit is overwritten on the next app launch.
These endpoints sit in front of that verdict. An override is keyed by userId
and is honoured only when the receipt being validated came from Sandbox, so
a production customer is unaffected even if an override exists for their id.
Overrides are process-local and non-persistent: they do not survive a restart,
and in a multi-instance deployment they apply to the instance that received the
request. That is deliberate — this is a debugging aid, not a product feature.
Apple promotional offers (opt-in — separate mount and separate header)
| Endpoint | What it does |
|---|---|
POST /onesub/apple/offer-signature |
Sign an Apple promotional offer |
Mounted when apple.offerKeyId and apple.offerPrivateKey are set — independently of
adminSecret. Its auth header is X-Onesub-Offer-Secret (compared against adminSecret), not
X-Admin-Secret. If adminSecret is unset the endpoint is unauthenticated and the host is
responsible for securing it.
The full route list lives in packages/server/README.md and is
machine-checked: openapi.test.ts asserts the mounted routers against
packages/server/src/openapi.ts, and npm run docs:check asserts that spec against the README. A
route cannot ship undocumented.
Entitlements (opt-in — requires config.entitlements)
Map abstract feature flags to one or more product IDs. A user is entitled when they have an active subscription or a non-consumable purchase for any product in the list. Consumables are excluded — they grant a one-time resource, not an ongoing right.
app.use(createOneSubMiddleware({
...config,
entitlements: {
premium: { productIds: ['pro_monthly', 'pro_yearly', 'premium_unlock'] },
adFree: { productIds: ['remove_ads'] },
},
}));
| Endpoint | What it does |
|---|---|
GET /onesub/entitlement?userId=&id=premium |
Single check: { id, active, source, productId?, expiresAt? } |
GET /onesub/entitlements?userId= |
All entitlements in one round-trip — use on app launch / login |
SDK: const { entitlements, hasEntitlement } = useOneSub(); — fetches the full map from the server and re-evaluates on subscription change.
Metrics (opt-in — same config.adminSecret, same X-Admin-Secret header)
Read-only aggregate counts. Revenue metrics (MRR, ARR) require per-product price configuration and are not yet implemented.
| Endpoint | What it does |
|---|---|
GET /onesub/metrics/active |
Current active count: { total, activeSubscriptions, gracePeriodSubscriptions, nonConsumablePurchases, byProduct, byPlatform } |
GET /onesub/metrics/started?from=&to=&groupBy= |
Subscriptions started in a date range |
GET /onesub/metrics/expired?from=&to=&groupBy= |
Subscriptions expired or canceled in a date range |
GET /onesub/metrics/purchases/started?from=&to=&groupBy= |
Non-consumable purchases started in a date range |
from and to are ISO 8601 timestamps. Add groupBy=day to get a UTC-bucketed time series in the buckets field.
Consumables (coins, credits): Can be purchased multiple times. Each purchase is tracked.
Non-consumables (unlock premium, remove ads): Purchased once. Duplicate purchases are rejected with 409 NON_CONSUMABLE_ALREADY_OWNED.
// Validate a consumable purchase
const res = await fetch('https://api.yourapp.com/onesub/purchase/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
platform: 'apple',
receipt: transactionReceipt,
userId: 'user123',
productId: 'credits_100',
type: 'consumable', // or 'non_consumable'
}),
});
const { valid, purchase } = await res.json();
What's Under the Hood
- Apple: StoreKit 2 JWS verified against Apple Root CA G3 (full x5c chain), App Store Server API for status fetch fallback + CONSUMPTION_REQUEST response, JWT minting cached + Promise-deduped
- Google: OAuth2 service account → Play Developer API v3 (
subscriptionsv2.get) —subscriptionStateenum directly mapped to lifecycle states (no expiry/cancelReason inference) - Webhooks: Lifecycle classification for grace_period / on_hold / paused (Apple subtype + Google notification types),
acknowledgePurchaseauto-called for Google subs+IAP,voidedPurchasesNotificationrouted to right store,linkedPurchaseTokenchain tracking for plan changes - Refund policy: Choose
'immediate'(default — flip status=canceled) or'until_expiry'(keep entitlement until original expiry) - Outbound calls: All upstream fetches (Apple/Google APIs) wrapped with
AbortControllertimeout (default 10s) so a hung upstream doesn't pile up webhook handlers - Storage: Pluggable
SubscriptionStore+PurchaseStore— built-in in-memory, PostgreSQL (auto-backfilled columns viaALTER TABLE IF NOT EXISTS), and Redis implementations - Scale-out: Redis-backed cache/idempotency plus optional BullMQ webhook processing and dead-letter replay
- Multi-app: One server can isolate credentials for multiple Apple bundle IDs and Google package names via
config.apps; existing single-app config remains compatible - Operations: Admin/metrics endpoints, generated OpenAPI document, optional OpenTelemetry spans, and a self-hosted dashboard
- Validation: zod input validation, 50KB body limit, userId length checks
- Tests: Unit, integration-style, and multi-notification lifecycle scenarios run with Vitest
- Security: Full details →
- Troubleshooting: errorCode → cause → fix
- Migration: Per-version upgrade notes
- Local development: Clone, mock server, dashboard, and Unity →
- Configuration: Server, SDK, multi-app, and environment options →
- Deployment: PostgreSQL, Redis, BullMQ, webhooks, and operations →
- Testing: Vitest, mock lifecycle, store E2E, and Unity →
- Unity integration: Install, configure, purchase, restore, and entitlement handling →
Optional: React Native SDK
If you want a drop-in React hook + paywall component (built on react-native-iap):
npm install @jeonghwanko/onesub-sdk react-native-iap
import { OneSubProvider, useOneSub } from '@jeonghwanko/onesub-sdk';
// Wrap your app
<OneSubProvider config={{ serverUrl, productId }} userId={userId}>
<App />
</OneSubProvider>
// Subscriptions
const { isActive, subscribe, restore } = useOneSub();
// One-time products (consumable / non-consumable)
const { purchaseProduct, restoreProduct } = useOneSub();
// Purchase a consumable (e.g. coins)
const purchase = await purchaseProduct('credits_100', 'consumable');
// purchase is null if user cancelled, PurchaseInfo (+ action) on success
// Purchase a non-consumable (e.g. premium unlock)
const purchase = await purchaseProduct('premium_unlock', 'non_consumable');
if (purchase?.action === 'restored') {
// already owned — show "복원 완료" instead of "구매 완료"
}
// Restore a non-consumable from the store's purchase history
const restored = await restoreProduct('premium_unlock', 'non_consumable');
One purchase at a time — the hook exposes isBusy; disable every IAP button while it is true. purchaseProduct / restoreProduct throw OneSubError with CONCURRENT_PURCHASE if called anyway, so a refusal never looks like a user cancel. The full null-versus-throw contract is in the SDK README.
Mock mode — set config.mockMode: true to return synthetic success from subscribe / restore / purchaseProduct / restoreProduct without calling react-native-iap or the onesub server. Useful for running UI flows in Expo Go / the simulator. Never enable in production.
Peer dependency: SDK requires react-native-iap v15+ (event-based purchase flow).
The SDK is optional. You can use @onesub/server with any client — React Native, Flutter, or plain HTTP calls.
Optional: MCP Server (AI Integration)
For Codex, Claude Code, and other MCP clients — AI helps set up and test your subscription:
{ "mcpServers": { "onesub": { "command": "npx", "args": ["-y", "@onesub/mcp-server"] } } }
"Add a monthly subscription to my Expo app"
Useful first prompts:
Read AGENTS.md and docs/README.md. Explain how to develop and validate this repository locally.
Do not modify files yet.
Analyze this app and plan a OneSub integration for product pro_monthly. List required files,
environment variables, native dependencies, webhooks, and validation steps before editing.
Use the OneSub mock server at http://localhost:4100 to simulate a subscription purchase, transition
it to grace_period, and inspect the resulting user state.
See docs/AI-WORKFLOW.md for implementation, diagnosis, local simulation,
and safe store-product management prompt templates.
Documents for AI agents
Use SKILL.md as portable context when an agent is integrating OneSub into another
application. Contributors working in this repository should use AGENTS.md; Claude's
CLAUDE.md imports that same canonical guide so the two instruction sets do not drift.
Read
https://raw.githubusercontent.com/jeonghwanko/onesub/master/SKILL.mdthen integrate onesub into this project.
Packages
| Package | Version | What | Install |
|---|---|---|---|
@onesub/server |
Express middleware — receipt validation + webhooks | npm i @onesub/server |
|
@jeonghwanko/onesub-sdk |
React Native SDK — useOneSub() + <Paywall /> |
npm i @jeonghwanko/onesub-sdk |
|
@onesub/mcp-server |
MCP tools — AI creates products + paywalls | npx @onesub/mcp-server |
|
@onesub/providers |
App Store Connect + Google Play API wrappers (standalone) | npm i @onesub/providers |
|
@onesub/cli |
Scaffolds a starter server project | npx @onesub/cli init |
|
@onesub/shared |
Shared TypeScript types | Auto-installed | |
onesub-dashboard |
Self-hosted operations dashboard (Docker) | docker run -p 4101:4101 -e ONESUB_SERVER_URL=... ghcr.io/jeonghwanko/onesub-dashboard:latest |
|
com.onesub.unity |
0.2.0 |
Unity purchasing, restore, localized price, and server validation Core | Unity Package Manager (Git URL + ?path=/packages/unity) |
com.onesub.unity.platform-services |
0.2.0 |
Optional Unity sharing, review, leaderboard, and authentication helpers | Unity Package Manager (Git URL + ?path=/packages/unity-platform-services) |
vs RevenueCat
| RevenueCat | onesub | |
|---|---|---|
| Receipt validation | Their servers | Your server |
| Revenue share | 1% after $2.5K | 0% forever |
| Data ownership | Their database | Your database |
| Vendor lock-in | Yes | No (MIT open source) |
| Dashboard | Hosted | Self-hosted operations dashboard |
| Setup time | 2-3 hours | 10 minutes |
onesub is not a full RevenueCat replacement. RevenueCat provides hosted infrastructure, experiments, and deeper revenue analytics. OneSub provides a self-hosted operational dashboard and core lifecycle metrics for developers who want to own their subscription infrastructure.
Already on RevenueCat and curious? See docs/MIGRATE-FROM-REVENUECAT.md — a step-by-step guide covering client code, historical data, webhook switchover, and rollback.
Examples
Working examples to get you started in minutes:
| Example | What | Run |
|---|---|---|
examples/server |
Express server with receipt validation | npm start |
examples/expo-app |
Expo Router app with paywall | npx expo start |
# 1. Start the server
cd examples/server
cp .env.example .env # add your Apple/Google credentials
npm install && npm start
# ── or, full stack (server + Postgres + Redis) in one command ──
docker compose up # http://localhost:4100
# 2. Start the app (in another terminal)
cd examples/expo-app
npm install && npx expo start
Custom Store
Built-in PostgreSQL store, or bring your own:
import { SubscriptionStore } from '@onesub/server';
class RedisStore implements SubscriptionStore {
async save(sub) { /* ... */ }
async getByUserId(userId) { /* ... */ }
async getByTransactionId(txId) { /* ... */ }
}
app.use(createOneSubMiddleware({ ...config, store: new RedisStore() }));
The canonical Postgres schema is shipped with the package at
packages/server/sql/schema.sql. Apply it
with psql -f if you manage migrations externally, or let store.initSchema()
run it for you on startup.
Roadmap
- Apple StoreKit 2 receipt validation (JWS
x5cchain → Apple Root CA G3) - Google Play Billing v3 (
subscriptionsv2.getresource) - Webhook handlers (Apple V2 + Google RTDN, voided purchases included)
- Lifecycle states:
grace_period/on_hold/paused(correct classification, not inferred) - PostgreSQL subscription store + purchase store (auto-backfill via
ALTER TABLE IF NOT EXISTS) - React Native SDK + paywall components
- MCP server for AI-assisted setup
- Apple App Store Server API direct fetch — webhook miss recovery + Status sync
- Apple
CONSUMPTION_REQUESTresponse hook (server-signed JWT to Apple) - Google
acknowledgePurchaseauto-call (subs + non-consumable IAP) — no more 3-day auto-refunds - Google
linkedPurchaseTokencontinuity (plan upgrade userId inheritance) - Refund policy (
immediate|until_expiry) - Outbound fetch hardening (Apple JWT cache + Promise dedup,
AbortControllertimeout) - Security hardening (zod validation, body limits, signature verification)
- e2e lifecycle scenario test suite (multi-notification sequences)
- CLI scaffolding (
npx @onesub/cli init) - Apple Family Sharing (
FAMILY_SHAREDownership mapping) - Apple Promotional Offer server-side signing
- Google
oneTimeProductNotificationacknowledgement handling - Apple Transaction History API client
- Multi-app receipt validation (multiple bundle IDs/package names on one server)
- Unity purchasing and server-validation Core package
- Analytics dashboard (self-hosted Docker —
ghcr.io/jeonghwanko/onesub-dashboard) - Hosted service (no server needed)
Contributing
git clone https://github.com/jeonghwanko/onesub.git
cd onesub && npm ci && npm run build && npm test
See CONTRIBUTING.md for the contributor workflow, docs/README.md for the documentation index, and AGENTS.md for Codex/Claude repository conventions.
No comments yet
Be the first to share your take.