| name | onesub |
| description | Use this skill when the user wants to add in-app purchases (subscriptions, consumables, or non-consumables) to a React Native / Expo mobile app. onesub is the open-source server side of react-native-iap — one line of Express middleware validates Apple StoreKit 2 and Google Play Billing receipts. Pair it with the `@jeonghwanko/onesub-sdk` React Native hook for the client side. TRIGGER for any mention of: in-app purchase, IAP, subscription, react-native-iap receipt validation, App Store Connect server verification, Google Play Developer API, StoreKit 2 JWS, paywall. SKIP for: payment processing that is NOT App Store / Play Store (Stripe, PayPal, crypto), Web-only billing, server that doesn't use Express. |
onesub — integration skill
onesub is a TypeScript monorepo. The npm packages you can install:
| Package | Install | Use in |
|---|
@onesub/server | npm i express @onesub/server | Node.js backend (Express 4 or 5) |
@jeonghwanko/onesub-sdk | npm i react-native-iap @jeonghwanko/onesub-sdk | React Native / Expo app |
@onesub/shared | (transitive) | types only — auto-installed |
@onesub/providers | npm i @onesub/providers | Managing App Store Connect / Play products from your own code |
@onesub/mcp-server | npx -y @onesub/mcp-server | MCP-compatible AI clients |
@onesub/cli | npx @onesub/cli init | Scaffolds a starter server project |
Repo: https://github.com/jeonghwanko/onesub
Decision tree
- Do you have a backend? If no, run
npx @onesub/cli init <dir> to scaffold one. This creates server.ts + docker-compose.yml (Postgres + server) ready to run.
- Does your backend already use Express? Then just mount
createOneSubMiddleware(config) — it's a standard Express Router.
- Is your mobile app React Native / Expo? Add
OneSubProvider + useOneSub() — see client section below.
- Need only server-side validation without the React hook? That's fine —
@onesub/server works with any HTTP client (plain fetch, Flutter, native iOS/Android).
Server setup (one-liner)
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 },
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),
adminSecret: process.env.ADMIN_SECRET,
}));
app.listen(4100);
Endpoints mounted automatically
| Route | Purpose |
|---|
POST /onesub/validate | Verify subscription receipt (Apple JWS or Google token) |
GET /onesub/status?userId= | Check subscription state |
POST /onesub/webhook/apple | App Store Server Notifications V2 (JWS-verified) |
POST /onesub/webhook/google | Google Play RTDN (Pub/Sub JWT-verified) |
POST /onesub/purchase/validate | One-time purchase (consumable / non-consumable) |
GET /onesub/purchase/status?userId= | List user's one-time purchases |
DELETE /onesub/purchase/admin/:userId/:productId | Reset a non-consumable for re-testing (admin) |
POST /onesub/purchase/admin/grant | Manually insert a purchase record (admin) |
POST /onesub/purchase/admin/transfer | Reassign transactionId to a new user (admin) |
GET /onesub/admin/subscriptions?userId=&status=&productId=&platform=&limit=&offset= | Filtered + paginated subscription list (admin) |
GET /onesub/admin/subscriptions/:transactionId | Single subscription detail by originalTransactionId (admin) |
GET /onesub/admin/customers/:userId | Full per-user profile: subscriptions + purchases + entitlements (admin) |
POST /onesub/admin/sync-apple/:originalTransactionId | Refresh one subscription from Apple Status API (admin) |
GET /onesub/admin/webhook-deadletters | List failed durable webhook jobs (admin + BullMQ) |
POST /onesub/admin/webhook-replay/:id | Replay a failed durable webhook job (admin + BullMQ) |
GET /onesub/entitlement?userId=&id= | Single entitlement check (requires config.entitlements) |
GET /onesub/entitlements?userId= | All entitlements in one round-trip (requires config.entitlements) |
GET /onesub/metrics/active | Current active subscriber + purchaser counts (admin) |
GET /onesub/metrics/started?from=&to=&groupBy= | Subscriptions started in a date range (admin) |
GET /onesub/metrics/expired?from=&to=&groupBy= | Subscriptions expired/canceled in a date range (admin) |
GET /onesub/metrics/purchases/started?from=&to=&groupBy= | Non-consumable purchases started in a date range (admin) |
POST /onesub/apple/offer-signature | Sign an Apple promotional offer (requires offer credentials) |
Postgres schema
Canonical DDL ships at node_modules/@onesub/server/sql/schema.sql. Either:
- Let
store.initSchema() apply it on boot (dev-friendly), or
- Run
psql -f node_modules/@onesub/server/sql/schema.sql (prod-friendly), or
- Mount it into
docker-entrypoint-initdb.d/ (docker-compose).
Client setup — React Native / Expo
import { OneSubProvider, useOneSub } from '@jeonghwanko/onesub-sdk';
<OneSubProvider
config={{ serverUrl: 'https://api.yourapp.com', productId: 'pro_monthly' }}
userId={userId}
>
<App />
</OneSubProvider>
const {
isActive, subscribe, restore,
purchaseProduct, restoreProduct,
} = useOneSub();
if (!isActive) <Button onPress={subscribe} title="Subscribe" />;
const result = await purchaseProduct('credits_100', 'consumable');
const owned = await purchaseProduct('premium_unlock', 'non_consumable');
if (owned?.action === 'restored') { }
const restored = await restoreProduct('premium_unlock', 'non_consumable');
Peer dep: react-native-iap v15+ (event-based purchase flow).
Mock mode for Expo Go / simulator UI testing (no native module needed):
<OneSubProvider config={{ ...config, mockMode: true }} ... />
Product types
| Type | Example | Behavior |
|---|
| Subscription | pro_monthly | auto-renewable. Server tracks status via webhook |
| Non-consumable | premium_unlock, remove_ads | purchased once. Duplicate attempts return 409 or auto-reassigned from prior owner |
| Consumable | credits_100, coins_1000 | purchased many times. Apple: dedup by transactionId. Google: must be consumed via Play API (onesub does this automatically) |
Config reference
The commonly used fields. The full, canonical reference is
docs/CONFIGURATION.md —
consult it before concluding an option doesn't exist.
interface OneSubServerConfig {
apple?: {
bundleId: string;
sharedSecret?: string;
skipJwsVerification?: boolean;
mockMode?: boolean;
keyId?: string;
issuerId?: string;
privateKey?: string;
offerKeyId?: string;
offerPrivateKey?: string;
productReceiptMaxAgeHours?: number;
};
google?: {
packageName: string;
serviceAccountKey?: string;
pushAudience?: string;
pushServiceAccountEmail?: string;
mockMode?: boolean;
productReceiptMaxAgeHours?: number;
};
database: { url: string };
apps?: Array<{
id: string;
apple?: OneSubAppleConfig;
google?: OneSubGoogleConfig;
}>;
defaultAppId?: string;
adminSecret?: string;
entitlements?: Record<string, { productIds: string[] }>;
refundPolicy?: 'immediate' | 'until_expiry';
logger?: OneSubLogger;
}
For multi-app deployments, validation requests may send appId. Apple receipts can also resolve by
their embedded bundle ID. Google purchase tokens do not identify their package, so non-default
Google apps must send appId. Unknown explicit IDs fail closed instead of using another app's
credentials.
Environment variables the CLI template reads: APPLE_BUNDLE_ID, APPLE_SHARED_SECRET, GOOGLE_PACKAGE_NAME, GOOGLE_SERVICE_ACCOUNT_KEY, GOOGLE_PUSH_AUDIENCE, DATABASE_URL, ADMIN_SECRET, PORT, ONESUB_ALLOW_SANDBOX.
Troubleshooting common errors
Full catalog (every ONESUB_ERROR_CODE with cause and fix): https://github.com/jeonghwanko/onesub/blob/master/docs/RECEIPT-ERRORS.md
| Symptom | Likely cause | Fix |
|---|
400 Invalid signedPayload on Apple webhook | Bundle ID mismatch or JWS signature broken | Check config.apple.bundleId matches the app. Do not set skipJwsVerification in prod |
409 TRANSACTION_BELONGS_TO_OTHER_USER on consumable | Same receipt sent with different userId | Intentional — receipts can't be reused. For non-consumables 0.6.1+ auto-reassigns |
400 "Sandbox receipt in production" | TestFlight receipt on prod server | Set ONESUB_ALLOW_SANDBOX=true env on the prod server during QA |
SDK isActive stays false after purchase | Server didn't receive webhook | Verify webhook URL in App Store Connect / Pub/Sub push config. Check POST /onesub/webhook/* returns 2xx |
SDK throws RN_IAP_NOT_INSTALLED | Peer dep missing | npm i react-native-iap@^15 in the app |
| TestFlight purchase succeeds without sheet | Stale pending in StoreKit queue (fixed in sdk@0.5.1+) | Upgrade @jeonghwanko/onesub-sdk and rebuild |
Enable config.debug: true on the SDK for verbose [onesub] traces. Server logs are tagged [onesub/*] per provider/route.
When you need to look deeper
Preferred integration flow (for a new project)
- Scaffold server:
npx @onesub/cli init my-api && cd my-api && npm install
- Set Apple/Google credentials in
.env (copy from .env.example)
- Boot infra:
docker compose up -d db → schema auto-initializes
- Run server:
npm run dev
- Mobile app:
npm i react-native-iap@^15 @jeonghwanko/onesub-sdk — wrap root with OneSubProvider
- Configure store credentials (App Store Connect + Google Play Console):
- Apple: App-Specific Shared Secret →
APPLE_SHARED_SECRET
- Google: Service Account with "View financial data" → JSON to
GOOGLE_SERVICE_ACCOUNT_KEY (automate with @yoonion/mimi-seed-mcp: iam_create_service_account + iam_create_key + playstore_verify_service_account)
- Configure webhooks:
- Apple: App Store Server Notifications V2 →
POST https://api.yourapp.com/onesub/webhook/apple
- Google: Pub/Sub push subscription →
POST https://api.yourapp.com/onesub/webhook/google
- Test: sandbox purchase in the app → server responds
valid: true → isActive: true in hook
If a step fails, consult Troubleshooting above before changing code — most issues are configuration, not onesub bugs.