MIT / self-hosted셀프 호스팅 / 0% revenue share수수료 0% / Apple + Google
Receipt validation, store webhooks, subscription lifecycle, and entitlements — as one Express middleware running on your server, against your database. A React Native SDK ships with it; Unity, Flutter, and plain HTTP clients work just as well.
영수증 검증, 스토어 웹훅, 구독 라이프사이클, entitlement — 당신의 서버에서 당신의 DB에 대고 도는 Express 미들웨어 하나로 끝난다. React Native SDK가 함께 오지만 Unity·Flutter·순수 HTTP 클라이언트로도 똑같이 쓴다.
The client library opens the store sheet and hands you a receipt. Everything that makes a subscription actually work is server-side — and none of it ships in the box.
클라이언트 라이브러리는 스토어 결제창을 띄우고 영수증을 넘겨줄 뿐이다. 구독을 실제로 굴러가게 만드는 건 전부 서버 쪽 일이고, 그건 아무도 안 준다.
That's 2–3 weeks of work. Or one line:
2~3주짜리 작업이다. 아니면 한 줄:
app.use(createOneSubMiddleware(config));
onesub is not a service you sign up for. It is middleware you mount inside the backend you already run. Receipts and webhooks land on your host; nothing routes through us.
onesub은 가입해서 쓰는 서비스가 아니다. 이미 운영 중인 당신의 백엔드에 얹는 미들웨어다. 영수증도 웹훅도 당신의 호스트로 들어온다. 우리 쪽을 경유하는 건 아무것도 없다.
react-native-iap · Unity IAP · StoreKit · any client
StoreKit 2 · Play Billing
receipt / purchaseToken ↓ server notifications ↓ 영수증 / purchaseToken ↓ 서버 알림 ↓
app.use(createOneSubMiddleware(config))
POST /onesub/validate · GET /onesub/status · POST /onesub/webhook/apple · POST /onesub/webhook/google · GET /onesub/entitlements
POST /onesub/validate · GET /onesub/status · POST /onesub/webhook/apple · POST /onesub/webhook/google · GET /onesub/entitlements
↓ subscriptions · purchases · entitlements ↓ 구독 · 구매 · entitlement
Pluggable SubscriptionStore and PurchaseStore — or bring your own.
교체 가능한 SubscriptionStore · PurchaseStore — 직접 구현해 꽂아도 된다.
Webhooks are the fast path, not the only path. Apple's notifications are reliable but
not guaranteed, so /onesub/status can fall back to a direct App Store
Server API fetch; Google falls back to subscriptionsv2.get. A dropped
notification does not leave a user's state drifting.
웹훅은 빠른 경로일 뿐, 유일한 경로가 아니다. Apple 알림은 안정적이지만 보장되진 않는다.
그래서 /onesub/status는 App Store Server API 직접 조회로 폴백하고,
Google은 subscriptionsv2.get로 폴백한다. 알림 하나 놓쳤다고
유저 상태가 어긋난 채 남지 않는다.
Mount the middleware on the server. Ask one endpoint from the client. The React Native SDK is optional sugar on top of that same endpoint.
서버에 미들웨어를 얹는다. 클라이언트에선 엔드포인트 하나를 부른다. React Native SDK는 그 위에 얹는 선택 사항일 뿐이다.
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),
}));
Routes mount themselves. The canonical Postgres schema ships with the package at
sql/schema.sql, or store.initSchema() applies it on startup.
라우트는 알아서 마운트된다. Postgres 스키마는 패키지에 sql/schema.sql로
동봉되어 있고, store.initSchema()가 부팅 시 대신 적용해주기도 한다.
const res = await fetch('https://api.yourapp.com/onesub/status?userId=user123');
const { active } = await res.json();
// active: true → subscribed, false → not subscribed
No SDK required. This is the same endpoint the React Native SDK, the Unity package, and a Flutter app all call.
SDK 없이도 된다. React Native SDK도, Unity 패키지도, Flutter 앱도 결국 이 엔드포인트를 부른다.
const { isActive, subscribe, purchaseProduct, hasEntitlement } = useOneSub();
// Subscription — the product id comes from the Provider config,
// so subscribe() takes no argument.
await subscribe();
// One-time products take an explicit id and type.
await purchaseProduct('credits_100', 'consumable');
await purchaseProduct('premium_unlock', 'non_consumable');
// Entitlements are prefetched into a map — this read is synchronous.
if (hasEntitlement('premium')) { /* unlock the feature */ }
null and a thrown OneSubError mean different
things: null is “nothing happened and that's normal” (user cancelled,
or nothing to restore); a throw is a failure or a refusal. Only one IAP operation runs at a
time — the hook exposes isBusy.
null과 OneSubError throw는 다른 뜻이다.
null은 “아무 일도 안 일어났고 그게 정상”(유저 취소, 복원할 게 없음),
throw는 실패 또는 거부다. IAP 작업은 한 번에 하나만 돈다 — 훅이
isBusy를 내준다.
Most homegrown backends guess state from expiryTime and
cancelReason. onesub reads Apple's notification subtype and Google's
subscriptionState enum directly, so “your card failed but you still
have access” stays a different message from “your subscription is on hold.”
직접 만든 백엔드 대부분은 expiryTime과
cancelReason으로 상태를 추측한다. onesub은 Apple 알림의 subtype과
Google의 subscriptionState enum을 직접 읽는다. 그래야
“카드 결제는 실패했지만 아직 쓸 수 있음”과 “구독 정지됨”이 서로 다른 메시지로 남는다.
| State상태 | active | When언제 | UX hintUX 힌트 |
|---|---|---|---|
active | ✅ | paid period정상 결제 기간 | normal평소대로 |
grace_period | ✅ | payment failed, store still grants access결제 실패했지만 스토어가 아직 접근 허용 | “Check your payment method — you still have access”결제 정보 확인 필요 (계속 사용 가능) |
on_hold | ❌ | grace ended, billing retry continues유예 종료, 결제 재시도 중 | “Update your payment method”결제 정보를 업데이트하세요 |
paused | ❌ | user-voluntary pause (Google only)유저가 자발적으로 일시정지 (Google만) | “Resumes on {autoResumeTime}”재개 예정: {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 a missed EXPIRED
webhook and for the 'until_expiry' refund policy.
active는
(active || grace_period) && expiresAt > now로 계산된다.
expiresAt 검사는 EXPIRED 웹훅을 놓쳤을 때와
'until_expiry' 환불 정책을 위한 안전망이다.
The edge cases that cost weeks the first time you meet them, already handled.
처음 부딪히면 몇 주를 잡아먹는 엣지 케이스들, 이미 처리되어 있다.
StoreKit 2 JWS verified against Apple Root CA G3 through the whole x5c chain — not a decoded payload you hope is real.
StoreKit 2 JWS를 x5c 체인 전체를 따라 Apple Root CA G3까지 검증한다. payload만 디코딩하고 믿는 방식이 아니다.
purchases.subscriptionsv2.get returns a subscriptionState enum that maps straight onto lifecycle states.
purchases.subscriptionsv2.get가 돌려주는 subscriptionState enum이 라이프사이클 상태로 그대로 매핑된다.
Google auto-refunds anything you don't acknowledgePurchase within 3 days. onesub calls it automatically — for subscriptions and one-time IAP both.
Google은 3일 안에 acknowledgePurchase를 안 하면 자동 환불한다. onesub은 구독·일회성 IAP 양쪽 모두 자동으로 호출한다.
'immediate' flips status to canceled at once; 'until_expiry' keeps entitlement to the original expiry date.
'immediate'는 즉시 canceled로 뒤집고, 'until_expiry'는 원래 만료일까지 권한을 유지한다.
Map premium to any set of subscription and non-consumable products. Consumables are excluded by design — they grant a resource, not an ongoing right.
premium 하나에 구독·비소모성 상품 여러 개를 묶는다. 소모성은 의도적으로 제외된다 — 지속되는 권리가 아니라 일회성 자원이니까.
Redis-backed cache and idempotency, optional BullMQ webhook queue with dead-letter replay, OpenTelemetry spans, generated OpenAPI doc.
Redis 캐시·멱등성, 선택적 BullMQ 웹훅 큐와 dead-letter 재처리, OpenTelemetry 스팬, 자동 생성 OpenAPI 문서.
Isolate credentials for several Apple bundle ids and Google package names. An unknown appId never falls back to another app's keys.
여러 Apple 번들 ID·Google 패키지명의 자격증명을 격리한다. 모르는 appId가 다른 앱 키로 폴백하는 일은 없다.
Server-side signing of promotional offer payloads, plus Family Sharing ownership mapping and the CONSUMPTION_REQUEST response hook.
프로모션 오퍼 페이로드 서버 서명, 가족 공유 소유권 매핑, CONSUMPTION_REQUEST 응답 훅까지.
npx @onesub/cli dev runs a fully mocked server. Receipt prefixes like MOCK_EXPIRED drive deterministic scenarios with no store credentials.
npx @onesub/cli dev로 완전히 목킹된 서버가 뜬다. MOCK_EXPIRED 같은 영수증 프리픽스로 스토어 자격증명 없이 시나리오를 재현한다.
RevenueCat is good software. These are the two axes where a self-hosted answer wins: you own the surface, and you own the data.
RevenueCat은 좋은 제품이다. 셀프 호스팅이 이기는 축은 두 개다 — 인프라를 소유한다는 것, 그리고 데이터를 소유한다는 것.
| RevenueCat | onesub | |
|---|---|---|
| Receipt validation영수증 검증 | Their servers그쪽 서버 | Your server당신의 서버 |
| Revenue share수수료 | 1% after $2.5K$2.5K 이후 1% | 0% forever영구 0% |
| Data ownership데이터 소유 | Their database그쪽 DB | Your database당신의 DB |
| Vendor lock-in벤더 종속 | Yes있음 | No — MIT open source없음 — MIT 오픈소스 |
| Dashboard대시보드 | Hosted호스팅형 | Self-hosted, Docker셀프 호스팅, Docker |
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? There is a step-by-step migration guide covering client code, historical data, webhook switchover, and rollback.
onesub은 RevenueCat의 완전한 대체재가 아니다. RevenueCat은 호스팅 인프라, 실험(A/B), 더 깊은 매출 분석을 제공한다. onesub이 주는 건 셀프 호스팅 운영 대시보드와 핵심 라이프사이클 지표 — 구독 인프라를 직접 소유하고 싶은 개발자를 위한 것이다. 이미 RevenueCat을 쓰고 있다면 클라이언트 코드, 과거 데이터, 웹훅 전환, 롤백까지 다루는 단계별 마이그레이션 가이드가 있다.
Take the server alone, or the whole toolkit. Everything except the dashboard image is on npm.
서버만 가져다 써도 되고, 전체 툴킷을 써도 된다. 대시보드 이미지를 뺀 나머지는 전부 npm에 있다.
| Package패키지 | What역할 | Install설치 |
|---|---|---|
|
@onesub/server
|
Express middleware — receipt validation + webhooksExpress 미들웨어 — 영수증 검증 + 웹훅 | npm i @onesub/server |
|
@jeonghwanko/onesub-sdk
|
React Native SDK — useOneSub() + <Paywall />React Native SDK — useOneSub() + <Paywall /> |
npm i @jeonghwanko/onesub-sdk |
|
@onesub/mcp-server
|
MCP tools — AI sets up products and paywallsMCP 도구 — AI가 상품·페이월을 세팅 | npx @onesub/mcp-server |
|
@onesub/providers
|
App Store Connect + Google Play API wrappers, standaloneApp Store Connect + Google Play API 래퍼, 단독 사용 가능 | npm i @onesub/providers |
|
@onesub/cli
|
Scaffolds a starter server; dev runs a fully mocked one스타터 서버 스캐폴딩; dev는 완전 목킹 서버 실행 |
npx @onesub/cli init |
| onesub-dashboard | Self-hosted operations dashboard셀프 호스팅 운영 대시보드 | docker run ghcr.io/jeonghwanko/onesub-dashboard |
| com.onesub.unity | Unity purchasing, restore, localized price, server validationUnity 구매·복원·현지화 가격·서버 검증 | UPM Git URLUPM Git URL |
MIT licensed. No account, no dashboard signup, no revenue share. Clone it, read it, change it.
MIT 라이선스. 계정도, 대시보드 가입도, 수수료도 없다. 클론하고, 읽고, 고쳐 쓰면 된다.
npx @onesub/cli init