@entitlehub/react-native 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.7 — 2026-07-22
4
+
5
+ ### Fixed
6
+ - **A completed purchase could still be reported as failed.** After a timeout, `purchaseProduct()`
7
+ checked for confirmation exactly once. Store notifications are asynchronous, so that single
8
+ reading races the grant and sometimes loses — observed live: the entitlement was written seconds
9
+ after the check ran, and the user saw a purchase-failed error for a purchase that worked. It now
10
+ polls the store and the server with backoff (`reconcileMs`, default 30s) before giving up.
11
+ - **The timeout counted the user's own time.** The timer was armed when `purchaseProduct()` was
12
+ called — before the store's password / Face ID sheet appears — so a user who paused mid-purchase
13
+ tripped it. It now starts once the request is actually in flight, with a separate generous
14
+ backstop (`sheetTimeoutMs`, default 10m) so a stuck bridge still can't hang forever.
15
+
16
+ ### Changed
17
+ - **Rejections are now typed `PurchaseError`s — branch on `.code`, not the message.**
18
+ `purchase-cancelled`, `purchase-failed`, and the new `purchase-pending`. **`purchase-pending` is
19
+ not a failure**: the store took the money but hasn't confirmed yet. Show "still confirming" — the
20
+ entitlement lands on its own and `addCustomerInfoUpdateListener` fires when it does. `.pending`
21
+ and `.customerInfo` are on the error. The old untyped `"purchase-timeout"` message is gone.
22
+
23
+ ## 0.1.6 — 2026-07-21
24
+
25
+ ### Added
26
+ - Reports `react-native/<version>` to EntitleHub on each call, so the dashboard can tell you when
27
+ your SDK is behind a release with fixes in it — rather than you finding out by hitting the bug.
28
+ Version only; no device or user data.
29
+
3
30
  ## 0.1.5 — 2026-07-21
4
31
 
5
32
  ### Fixed
package/dist/index.cjs CHANGED
@@ -20,6 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ PurchaseError: () => PurchaseError,
23
24
  addCustomerInfoUpdateListener: () => addCustomerInfoUpdateListener,
24
25
  configureEntitleHub: () => configureEntitleHub,
25
26
  getCustomerInfo: () => getCustomerInfo,
@@ -42,6 +43,7 @@ function iap() {
42
43
  }
43
44
  return _iap;
44
45
  }
46
+ var RN_SDK_VERSION = "0.1.7";
45
47
  var client;
46
48
  function eh() {
47
49
  if (!client) throw new Error("Call configureEntitleHub({ apiKey, appUserId }) before using EntitleHub.");
@@ -49,7 +51,12 @@ function eh() {
49
51
  }
50
52
  async function configureEntitleHub(opts) {
51
53
  if (opts.iap) _iap = opts.iap;
52
- client = new import_sdk.EntitleHub({ apiKey: opts.apiKey, appUserId: opts.appUserId, baseUrl: opts.baseUrl });
54
+ client = new import_sdk.EntitleHub({
55
+ apiKey: opts.apiKey,
56
+ appUserId: opts.appUserId,
57
+ baseUrl: opts.baseUrl,
58
+ client: `react-native/${RN_SDK_VERSION}`
59
+ });
53
60
  try {
54
61
  await iap().initConnection();
55
62
  } catch {
@@ -71,11 +78,25 @@ async function getProducts(productIds, type = "all") {
71
78
  return iap().fetchProducts({ skus: productIds, type });
72
79
  }
73
80
  var DEFAULT_PURCHASE_TIMEOUT_MS = 12e4;
81
+ var DEFAULT_SHEET_TIMEOUT_MS = 6e5;
82
+ var DEFAULT_RECONCILE_MS = 3e4;
83
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
84
+ var PurchaseError = class extends Error {
85
+ constructor(code, message, customerInfo) {
86
+ super(message);
87
+ this.name = "PurchaseError";
88
+ this.code = code;
89
+ this.pending = code === "purchase-pending";
90
+ this.customerInfo = customerInfo;
91
+ }
92
+ };
74
93
  async function purchaseProduct(productId, opts = {}) {
75
94
  const I = iap();
76
95
  const isSub = Boolean(opts.isSubscription);
77
96
  const isConsumable = Boolean(opts.isConsumable) && !isSub;
78
97
  const timeoutMs = opts.timeoutMs ?? DEFAULT_PURCHASE_TIMEOUT_MS;
98
+ const sheetTimeoutMs = opts.sheetTimeoutMs ?? DEFAULT_SHEET_TIMEOUT_MS;
99
+ const reconcileMs = opts.reconcileMs ?? DEFAULT_RECONCILE_MS;
79
100
  let entitlementsBefore = [];
80
101
  try {
81
102
  entitlementsBefore = (await eh().getCustomerInfo()).activeEntitlementIds;
@@ -130,26 +151,43 @@ async function purchaseProduct(productId, opts = {}) {
130
151
  };
131
152
  const isPurchase = (p) => Boolean(p) && Boolean(p.purchaseToken || p.jwsRepresentation || p.jwsRepresentationIos || p.purchaseTokenAndroid || p.transactionId || p.transactionReceipt || p.id);
132
153
  const reconcile = async () => {
133
- if (settled) return;
134
- try {
135
- const owned = await I.getAvailablePurchases() || [];
136
- const match = owned.find((p) => (p?.productId ?? p?.id ?? p?.sku) === productId);
137
- if (match) return settleWithPurchase(match);
138
- } catch {
139
- }
140
- try {
141
- const info = await eh().getCustomerInfo({ fetchPolicy: "network-only" });
142
- const grew = info.activeEntitlementIds.some((id) => !entitlementsBefore.includes(id));
143
- if (grew) {
144
- if (settled) return;
145
- settled = true;
146
- cleanup();
147
- resolve(info);
148
- return;
154
+ const deadline = Date.now() + reconcileMs;
155
+ let wait = 2e3;
156
+ let lastInfo;
157
+ while (!settled) {
158
+ try {
159
+ const owned = await I.getAvailablePurchases() || [];
160
+ const match = owned.find((p) => (p?.productId ?? p?.id ?? p?.sku) === productId);
161
+ if (match) return settleWithPurchase(match);
162
+ } catch {
149
163
  }
150
- } catch {
164
+ try {
165
+ lastInfo = await eh().getCustomerInfo({ fetchPolicy: "network-only" });
166
+ if (lastInfo.activeEntitlementIds.some((id) => !entitlementsBefore.includes(id))) {
167
+ if (settled) return;
168
+ settled = true;
169
+ cleanup();
170
+ resolve(lastInfo);
171
+ return;
172
+ }
173
+ } catch {
174
+ }
175
+ const remaining = deadline - Date.now();
176
+ if (remaining <= 0) break;
177
+ await sleep(Math.min(wait, remaining));
178
+ wait = Math.min(wait * 1.5, 8e3);
151
179
  }
152
- fail(new Error("purchase-timeout"));
180
+ fail(new PurchaseError(
181
+ "purchase-pending",
182
+ "The store hasn't confirmed this purchase yet. It may still complete \u2014 show a pending state, not a failure.",
183
+ lastInfo
184
+ ));
185
+ };
186
+ const armTimer = (ms) => {
187
+ if (timer) clearTimeout(timer);
188
+ timer = setTimeout(() => {
189
+ void reconcile();
190
+ }, ms);
153
191
  };
154
192
  const updSub = I.purchaseUpdatedListener((purchase) => {
155
193
  void settleWithPurchase(purchase);
@@ -157,17 +195,21 @@ async function purchaseProduct(productId, opts = {}) {
157
195
  const errSub = I.purchaseErrorListener((error) => {
158
196
  const code = error?.code;
159
197
  const cancelled = code === "E_USER_CANCELLED" || code === "user-cancelled" || /cancel/i.test(String(error?.message));
160
- fail(new Error(cancelled ? "purchase-cancelled" : error?.message || "purchase-failed"));
198
+ fail(cancelled ? new PurchaseError("purchase-cancelled", "The user cancelled the purchase.") : new PurchaseError("purchase-failed", error?.message || "The purchase failed."));
161
199
  });
162
200
  Promise.resolve(
163
201
  I.requestPurchase({ request, type: isSub ? "subs" : "in-app" })
164
202
  ).then((res) => {
165
203
  const p = Array.isArray(res) ? res.find(isPurchase) : res;
166
- if (isPurchase(p)) void settleWithPurchase(p);
167
- }).catch((e) => fail(e));
168
- timer = setTimeout(() => {
169
- void reconcile();
170
- }, timeoutMs);
204
+ if (isPurchase(p)) {
205
+ void settleWithPurchase(p);
206
+ return;
207
+ }
208
+ armTimer(timeoutMs);
209
+ }).catch((e) => fail(
210
+ e instanceof PurchaseError ? e : new PurchaseError("purchase-failed", e?.message || "The purchase failed.")
211
+ ));
212
+ armTimer(sheetTimeoutMs);
171
213
  });
172
214
  }
173
215
  async function restorePurchases() {
@@ -198,6 +240,7 @@ async function reportToEntitleHub(purchase, productId, isSubscription) {
198
240
  }
199
241
  // Annotate the CommonJS export names for ESM import in node:
200
242
  0 && (module.exports = {
243
+ PurchaseError,
201
244
  addCustomerInfoUpdateListener,
202
245
  configureEntitleHub,
203
246
  getCustomerInfo,
package/dist/index.d.cts CHANGED
@@ -31,6 +31,23 @@ declare function isEntitled(entitlementId: string): Promise<boolean>;
31
31
  declare function getOfferings(): Promise<Offerings>;
32
32
  /** Live store products (prices, localized titles) from the native store (expo-iap `fetchProducts`). */
33
33
  declare function getProducts(productIds: string[], type?: "in-app" | "subs" | "all"): Promise<any[]>;
34
+ /** Why a purchase didn't complete. Check `code` — never match on the message. */
35
+ type PurchaseErrorCode = "purchase-cancelled" | "purchase-pending" | "purchase-failed";
36
+ /**
37
+ * A purchase that didn't return entitlements.
38
+ *
39
+ * `code === "purchase-pending"` is NOT a failure: the store took the money but confirmation hasn't
40
+ * reached us yet (store notifications are asynchronous). Show "still confirming", not an error —
41
+ * the entitlement will arrive on its own, and `addCustomerInfoUpdateListener` will fire when it does.
42
+ */
43
+ declare class PurchaseError extends Error {
44
+ readonly code: PurchaseErrorCode;
45
+ /** True when the purchase probably succeeded and is just unconfirmed. */
46
+ readonly pending: boolean;
47
+ /** Last known entitlements, if we managed to read them. */
48
+ readonly customerInfo?: CustomerInfo;
49
+ constructor(code: PurchaseErrorCode, message: string, customerInfo?: CustomerInfo);
50
+ }
34
51
  /**
35
52
  * Buy a product in one call: open the native purchase sheet, validate the receipt with EntitleHub,
36
53
  * and return the updated entitlements. Purchase + entitlement sync, the RevenueCat way.
@@ -44,17 +61,25 @@ declare function getProducts(productIds: string[], type?: "in-app" | "subs" | "a
44
61
  * whether the entitlement appeared server-side anyway (EntitleHub's store-notification webhook
45
62
  * grants it independently of the client).
46
63
  *
47
- * Rejects with "purchase-cancelled" if the user backs out, "purchase-timeout" if nothing arrived
48
- * and nothing was granted.
64
+ * Rejects with a `PurchaseError`; branch on `.code`, never the message:
65
+ * - `purchase-cancelled` the user backed out.
66
+ * - `purchase-pending` — the store took the money but hasn't confirmed. NOT a failure; show a
67
+ * pending state. `.pending` is true and the entitlement will land on its own.
68
+ * - `purchase-failed` — a real failure.
49
69
  */
50
70
  declare function purchaseProduct(productId: string, opts?: {
51
71
  isSubscription?: boolean;
52
72
  isConsumable?: boolean;
73
+ /** Wait for the store's result once the request is in flight (default 120s). */
53
74
  timeoutMs?: number;
75
+ /** Absolute backstop from the call, covering time spent in the store's own sheet (default 10m). */
76
+ sheetTimeoutMs?: number;
77
+ /** How long to poll for confirmation before reporting pending (default 30s). */
78
+ reconcileMs?: number;
54
79
  }): Promise<CustomerInfo>;
55
80
  /** Restore the user's purchases: re-validate each with EntitleHub, then return entitlements. */
56
81
  declare function restorePurchases(): Promise<CustomerInfo>;
57
82
  /** Subscribe to entitlement changes (e.g. after a purchase). Returns an unsubscribe function. */
58
83
  declare function addCustomerInfoUpdateListener(fn: (info: CustomerInfo) => void): () => void;
59
84
 
60
- export { type ConfigureOptions, addCustomerInfoUpdateListener, configureEntitleHub, getCustomerInfo, getOfferings, getProducts, isEntitled, logIn, purchaseProduct, restorePurchases };
85
+ export { type ConfigureOptions, PurchaseError, type PurchaseErrorCode, addCustomerInfoUpdateListener, configureEntitleHub, getCustomerInfo, getOfferings, getProducts, isEntitled, logIn, purchaseProduct, restorePurchases };
package/dist/index.d.ts CHANGED
@@ -31,6 +31,23 @@ declare function isEntitled(entitlementId: string): Promise<boolean>;
31
31
  declare function getOfferings(): Promise<Offerings>;
32
32
  /** Live store products (prices, localized titles) from the native store (expo-iap `fetchProducts`). */
33
33
  declare function getProducts(productIds: string[], type?: "in-app" | "subs" | "all"): Promise<any[]>;
34
+ /** Why a purchase didn't complete. Check `code` — never match on the message. */
35
+ type PurchaseErrorCode = "purchase-cancelled" | "purchase-pending" | "purchase-failed";
36
+ /**
37
+ * A purchase that didn't return entitlements.
38
+ *
39
+ * `code === "purchase-pending"` is NOT a failure: the store took the money but confirmation hasn't
40
+ * reached us yet (store notifications are asynchronous). Show "still confirming", not an error —
41
+ * the entitlement will arrive on its own, and `addCustomerInfoUpdateListener` will fire when it does.
42
+ */
43
+ declare class PurchaseError extends Error {
44
+ readonly code: PurchaseErrorCode;
45
+ /** True when the purchase probably succeeded and is just unconfirmed. */
46
+ readonly pending: boolean;
47
+ /** Last known entitlements, if we managed to read them. */
48
+ readonly customerInfo?: CustomerInfo;
49
+ constructor(code: PurchaseErrorCode, message: string, customerInfo?: CustomerInfo);
50
+ }
34
51
  /**
35
52
  * Buy a product in one call: open the native purchase sheet, validate the receipt with EntitleHub,
36
53
  * and return the updated entitlements. Purchase + entitlement sync, the RevenueCat way.
@@ -44,17 +61,25 @@ declare function getProducts(productIds: string[], type?: "in-app" | "subs" | "a
44
61
  * whether the entitlement appeared server-side anyway (EntitleHub's store-notification webhook
45
62
  * grants it independently of the client).
46
63
  *
47
- * Rejects with "purchase-cancelled" if the user backs out, "purchase-timeout" if nothing arrived
48
- * and nothing was granted.
64
+ * Rejects with a `PurchaseError`; branch on `.code`, never the message:
65
+ * - `purchase-cancelled` the user backed out.
66
+ * - `purchase-pending` — the store took the money but hasn't confirmed. NOT a failure; show a
67
+ * pending state. `.pending` is true and the entitlement will land on its own.
68
+ * - `purchase-failed` — a real failure.
49
69
  */
50
70
  declare function purchaseProduct(productId: string, opts?: {
51
71
  isSubscription?: boolean;
52
72
  isConsumable?: boolean;
73
+ /** Wait for the store's result once the request is in flight (default 120s). */
53
74
  timeoutMs?: number;
75
+ /** Absolute backstop from the call, covering time spent in the store's own sheet (default 10m). */
76
+ sheetTimeoutMs?: number;
77
+ /** How long to poll for confirmation before reporting pending (default 30s). */
78
+ reconcileMs?: number;
54
79
  }): Promise<CustomerInfo>;
55
80
  /** Restore the user's purchases: re-validate each with EntitleHub, then return entitlements. */
56
81
  declare function restorePurchases(): Promise<CustomerInfo>;
57
82
  /** Subscribe to entitlement changes (e.g. after a purchase). Returns an unsubscribe function. */
58
83
  declare function addCustomerInfoUpdateListener(fn: (info: CustomerInfo) => void): () => void;
59
84
 
60
- export { type ConfigureOptions, addCustomerInfoUpdateListener, configureEntitleHub, getCustomerInfo, getOfferings, getProducts, isEntitled, logIn, purchaseProduct, restorePurchases };
85
+ export { type ConfigureOptions, PurchaseError, type PurchaseErrorCode, addCustomerInfoUpdateListener, configureEntitleHub, getCustomerInfo, getOfferings, getProducts, isEntitled, logIn, purchaseProduct, restorePurchases };
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ function iap() {
17
17
  }
18
18
  return _iap;
19
19
  }
20
+ var RN_SDK_VERSION = "0.1.7";
20
21
  var client;
21
22
  function eh() {
22
23
  if (!client) throw new Error("Call configureEntitleHub({ apiKey, appUserId }) before using EntitleHub.");
@@ -24,7 +25,12 @@ function eh() {
24
25
  }
25
26
  async function configureEntitleHub(opts) {
26
27
  if (opts.iap) _iap = opts.iap;
27
- client = new EntitleHub({ apiKey: opts.apiKey, appUserId: opts.appUserId, baseUrl: opts.baseUrl });
28
+ client = new EntitleHub({
29
+ apiKey: opts.apiKey,
30
+ appUserId: opts.appUserId,
31
+ baseUrl: opts.baseUrl,
32
+ client: `react-native/${RN_SDK_VERSION}`
33
+ });
28
34
  try {
29
35
  await iap().initConnection();
30
36
  } catch {
@@ -46,11 +52,25 @@ async function getProducts(productIds, type = "all") {
46
52
  return iap().fetchProducts({ skus: productIds, type });
47
53
  }
48
54
  var DEFAULT_PURCHASE_TIMEOUT_MS = 12e4;
55
+ var DEFAULT_SHEET_TIMEOUT_MS = 6e5;
56
+ var DEFAULT_RECONCILE_MS = 3e4;
57
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
58
+ var PurchaseError = class extends Error {
59
+ constructor(code, message, customerInfo) {
60
+ super(message);
61
+ this.name = "PurchaseError";
62
+ this.code = code;
63
+ this.pending = code === "purchase-pending";
64
+ this.customerInfo = customerInfo;
65
+ }
66
+ };
49
67
  async function purchaseProduct(productId, opts = {}) {
50
68
  const I = iap();
51
69
  const isSub = Boolean(opts.isSubscription);
52
70
  const isConsumable = Boolean(opts.isConsumable) && !isSub;
53
71
  const timeoutMs = opts.timeoutMs ?? DEFAULT_PURCHASE_TIMEOUT_MS;
72
+ const sheetTimeoutMs = opts.sheetTimeoutMs ?? DEFAULT_SHEET_TIMEOUT_MS;
73
+ const reconcileMs = opts.reconcileMs ?? DEFAULT_RECONCILE_MS;
54
74
  let entitlementsBefore = [];
55
75
  try {
56
76
  entitlementsBefore = (await eh().getCustomerInfo()).activeEntitlementIds;
@@ -105,26 +125,43 @@ async function purchaseProduct(productId, opts = {}) {
105
125
  };
106
126
  const isPurchase = (p) => Boolean(p) && Boolean(p.purchaseToken || p.jwsRepresentation || p.jwsRepresentationIos || p.purchaseTokenAndroid || p.transactionId || p.transactionReceipt || p.id);
107
127
  const reconcile = async () => {
108
- if (settled) return;
109
- try {
110
- const owned = await I.getAvailablePurchases() || [];
111
- const match = owned.find((p) => (p?.productId ?? p?.id ?? p?.sku) === productId);
112
- if (match) return settleWithPurchase(match);
113
- } catch {
114
- }
115
- try {
116
- const info = await eh().getCustomerInfo({ fetchPolicy: "network-only" });
117
- const grew = info.activeEntitlementIds.some((id) => !entitlementsBefore.includes(id));
118
- if (grew) {
119
- if (settled) return;
120
- settled = true;
121
- cleanup();
122
- resolve(info);
123
- return;
128
+ const deadline = Date.now() + reconcileMs;
129
+ let wait = 2e3;
130
+ let lastInfo;
131
+ while (!settled) {
132
+ try {
133
+ const owned = await I.getAvailablePurchases() || [];
134
+ const match = owned.find((p) => (p?.productId ?? p?.id ?? p?.sku) === productId);
135
+ if (match) return settleWithPurchase(match);
136
+ } catch {
124
137
  }
125
- } catch {
138
+ try {
139
+ lastInfo = await eh().getCustomerInfo({ fetchPolicy: "network-only" });
140
+ if (lastInfo.activeEntitlementIds.some((id) => !entitlementsBefore.includes(id))) {
141
+ if (settled) return;
142
+ settled = true;
143
+ cleanup();
144
+ resolve(lastInfo);
145
+ return;
146
+ }
147
+ } catch {
148
+ }
149
+ const remaining = deadline - Date.now();
150
+ if (remaining <= 0) break;
151
+ await sleep(Math.min(wait, remaining));
152
+ wait = Math.min(wait * 1.5, 8e3);
126
153
  }
127
- fail(new Error("purchase-timeout"));
154
+ fail(new PurchaseError(
155
+ "purchase-pending",
156
+ "The store hasn't confirmed this purchase yet. It may still complete \u2014 show a pending state, not a failure.",
157
+ lastInfo
158
+ ));
159
+ };
160
+ const armTimer = (ms) => {
161
+ if (timer) clearTimeout(timer);
162
+ timer = setTimeout(() => {
163
+ void reconcile();
164
+ }, ms);
128
165
  };
129
166
  const updSub = I.purchaseUpdatedListener((purchase) => {
130
167
  void settleWithPurchase(purchase);
@@ -132,17 +169,21 @@ async function purchaseProduct(productId, opts = {}) {
132
169
  const errSub = I.purchaseErrorListener((error) => {
133
170
  const code = error?.code;
134
171
  const cancelled = code === "E_USER_CANCELLED" || code === "user-cancelled" || /cancel/i.test(String(error?.message));
135
- fail(new Error(cancelled ? "purchase-cancelled" : error?.message || "purchase-failed"));
172
+ fail(cancelled ? new PurchaseError("purchase-cancelled", "The user cancelled the purchase.") : new PurchaseError("purchase-failed", error?.message || "The purchase failed."));
136
173
  });
137
174
  Promise.resolve(
138
175
  I.requestPurchase({ request, type: isSub ? "subs" : "in-app" })
139
176
  ).then((res) => {
140
177
  const p = Array.isArray(res) ? res.find(isPurchase) : res;
141
- if (isPurchase(p)) void settleWithPurchase(p);
142
- }).catch((e) => fail(e));
143
- timer = setTimeout(() => {
144
- void reconcile();
145
- }, timeoutMs);
178
+ if (isPurchase(p)) {
179
+ void settleWithPurchase(p);
180
+ return;
181
+ }
182
+ armTimer(timeoutMs);
183
+ }).catch((e) => fail(
184
+ e instanceof PurchaseError ? e : new PurchaseError("purchase-failed", e?.message || "The purchase failed.")
185
+ ));
186
+ armTimer(sheetTimeoutMs);
146
187
  });
147
188
  }
148
189
  async function restorePurchases() {
@@ -172,6 +213,7 @@ async function reportToEntitleHub(purchase, productId, isSubscription) {
172
213
  throw new Error("Could not read a receipt from the purchase (no iOS JWS or Android purchase token).");
173
214
  }
174
215
  export {
216
+ PurchaseError,
175
217
  addCustomerInfoUpdateListener,
176
218
  configureEntitleHub,
177
219
  getCustomerInfo,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entitlehub/react-native",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "EntitleHub for React Native & Expo — one call to purchase and unlock entitlements. Wraps expo-iap for the native store sheet and validates receipts via EntitleHub.",
5
5
  "keywords": [
6
6
  "entitlehub",