expo-iap 4.2.2 → 4.2.4

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.
Files changed (40) hide show
  1. package/build/index.d.ts +126 -33
  2. package/build/index.d.ts.map +1 -1
  3. package/build/index.js +123 -33
  4. package/build/index.js.map +1 -1
  5. package/build/kit-api.d.ts +54 -0
  6. package/build/kit-api.d.ts.map +1 -0
  7. package/build/kit-api.js +156 -0
  8. package/build/kit-api.js.map +1 -0
  9. package/build/modules/android.d.ts +22 -0
  10. package/build/modules/android.d.ts.map +1 -1
  11. package/build/modules/android.js +37 -0
  12. package/build/modules/android.js.map +1 -1
  13. package/build/modules/ios.d.ts +69 -1
  14. package/build/modules/ios.d.ts.map +1 -1
  15. package/build/modules/ios.js +73 -1
  16. package/build/modules/ios.js.map +1 -1
  17. package/build/types.d.ts +241 -75
  18. package/build/types.d.ts.map +1 -1
  19. package/build/types.js.map +1 -1
  20. package/build/useIAP.d.ts.map +1 -1
  21. package/build/useIAP.js +125 -3
  22. package/build/useIAP.js.map +1 -1
  23. package/build/useWebhookEvents.d.ts +26 -0
  24. package/build/useWebhookEvents.d.ts.map +1 -0
  25. package/build/useWebhookEvents.js +105 -0
  26. package/build/useWebhookEvents.js.map +1 -0
  27. package/build/webhook-client.d.ts +82 -0
  28. package/build/webhook-client.d.ts.map +1 -0
  29. package/build/webhook-client.js +176 -0
  30. package/build/webhook-client.js.map +1 -0
  31. package/openiap-versions.json +2 -2
  32. package/package.json +1 -1
  33. package/src/index.ts +141 -33
  34. package/src/kit-api.ts +229 -0
  35. package/src/modules/android.ts +47 -0
  36. package/src/modules/ios.ts +74 -1
  37. package/src/types.ts +247 -75
  38. package/src/useIAP.ts +125 -3
  39. package/src/useWebhookEvents.ts +155 -0
  40. package/src/webhook-client.ts +314 -0
@@ -0,0 +1,176 @@
1
+ // Transport-agnostic webhook client for the openiap kit SSE stream
2
+ // (`GET /v1/webhooks/stream/{apiKey}`). Used by the JavaScript / TS
3
+ // wrappers (react-native-iap, expo-iap) but written without React or
4
+ // React-Native imports so it can also run in plain Node, browser, or
5
+ // any other JS runtime.
6
+ //
7
+ // The wire format is documented in `packages/kit/server/api/v1/webhooks.ts`
8
+ // and matches the GraphQL `WebhookEvent` shape from `webhook.graphql`.
9
+ //
10
+ // Parser logic is split out from the connection so it can be unit-
11
+ // tested without a live server. See `webhook-client.test.ts`.
12
+ export const WEBHOOK_EVENT_TYPES = [
13
+ 'SubscriptionStarted',
14
+ 'SubscriptionRenewed',
15
+ 'SubscriptionExpired',
16
+ 'SubscriptionInGracePeriod',
17
+ 'SubscriptionInBillingRetry',
18
+ 'SubscriptionRecovered',
19
+ 'SubscriptionCanceled',
20
+ 'SubscriptionUncanceled',
21
+ 'SubscriptionRevoked',
22
+ 'SubscriptionPriceChange',
23
+ 'SubscriptionProductChanged',
24
+ 'SubscriptionPaused',
25
+ 'SubscriptionResumed',
26
+ 'PurchaseRefunded',
27
+ 'PurchaseConsumptionRequest',
28
+ 'TestNotification',
29
+ ];
30
+ const DEFAULT_BASE_URL = 'https://kit.openiap.dev';
31
+ export function connectWebhookStream(options) {
32
+ const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
33
+ const url = `${trimTrailingSlash(baseUrl)}/v1/webhooks/stream/${encodeURIComponent(options.apiKey)}`;
34
+ const factory = options.eventSourceFactory ?? defaultEventSourceFactory;
35
+ let stream;
36
+ try {
37
+ stream = factory(url, {});
38
+ }
39
+ catch (error) {
40
+ options.onError?.({
41
+ code: 'NO_EVENTSOURCE',
42
+ message: error instanceof Error
43
+ ? error.message
44
+ : 'EventSource constructor unavailable in this runtime',
45
+ cause: error,
46
+ });
47
+ return { close: () => { } };
48
+ }
49
+ const seenIds = new Set();
50
+ const seenOrder = [];
51
+ const markSeen = (id) => {
52
+ if (seenIds.has(id)) {
53
+ return true;
54
+ }
55
+ seenIds.add(id);
56
+ seenOrder.push(id);
57
+ if (seenOrder.length > 1024) {
58
+ const evicted = seenOrder.shift();
59
+ if (evicted !== undefined) {
60
+ seenIds.delete(evicted);
61
+ }
62
+ }
63
+ return false;
64
+ };
65
+ const handleData = (raw) => {
66
+ const parsed = parseWebhookEventData(raw);
67
+ if (parsed.kind === 'error') {
68
+ options.onError?.({
69
+ code: 'PARSE_ERROR',
70
+ message: parsed.message,
71
+ });
72
+ return;
73
+ }
74
+ if (parsed.kind === 'skip') {
75
+ return;
76
+ }
77
+ if (markSeen(parsed.event.id)) {
78
+ return;
79
+ }
80
+ options.onEvent(parsed.event);
81
+ };
82
+ if (typeof stream.addEventListener === 'function') {
83
+ stream.addEventListener('message', (event) => handleData(event.data));
84
+ // WHATWG EventSource dispatches frames with `event: Foo` only to
85
+ // listeners registered for `Foo`, not to `message` / `onmessage`.
86
+ // Kit emits webhook frames as typed SSE events, so subscribe to
87
+ // every known webhook type and keep `message` for older servers or
88
+ // polyfills that collapse typed frames into the generic channel.
89
+ for (const eventType of WEBHOOK_EVENT_TYPES) {
90
+ stream.addEventListener(eventType, (event) => handleData(event.data));
91
+ }
92
+ }
93
+ else {
94
+ stream.onmessage = (event) => handleData(event.data);
95
+ }
96
+ stream.onerror = (error) => {
97
+ options.onError?.({
98
+ code: 'TRANSPORT_ERROR',
99
+ message: 'SSE transport error (auto-reconnecting)',
100
+ cause: error,
101
+ });
102
+ };
103
+ return {
104
+ close: () => {
105
+ try {
106
+ stream.close();
107
+ }
108
+ catch {
109
+ // Closing an already-closed EventSource is a no-op in browsers
110
+ // but throws in some polyfills.
111
+ }
112
+ },
113
+ };
114
+ }
115
+ export function parseWebhookEventData(raw) {
116
+ if (!raw) {
117
+ return { kind: 'skip', reason: 'heartbeat' };
118
+ }
119
+ let parsed;
120
+ try {
121
+ parsed = JSON.parse(raw);
122
+ }
123
+ catch (error) {
124
+ return {
125
+ kind: 'error',
126
+ message: error instanceof Error
127
+ ? `Failed to parse SSE payload: ${error.message}`
128
+ : 'Failed to parse SSE payload',
129
+ };
130
+ }
131
+ if (typeof parsed !== 'object' ||
132
+ parsed === null ||
133
+ !('type' in parsed) ||
134
+ typeof parsed.type !== 'string') {
135
+ // Stream-control messages (the `ready`/`stream-error` envelopes
136
+ // emitted by the kit server) have no `type` and are surfaced as
137
+ // skips so consumers don't see them as events.
138
+ return { kind: 'skip', reason: 'stream-control' };
139
+ }
140
+ const event = parsed;
141
+ if (typeof event.id !== 'string' ||
142
+ typeof event.occurredAt !== 'number' ||
143
+ typeof event.receivedAt !== 'number') {
144
+ return {
145
+ kind: 'error',
146
+ message: `WebhookEvent missing required fields (id/occurredAt/receivedAt)`,
147
+ };
148
+ }
149
+ // purchaseToken is required for every event type *except*
150
+ // TestNotification — Apple ASN v2 / Google RTDN test payloads
151
+ // carry no transaction. Hard-rejecting here would surface valid
152
+ // test webhooks as MALFORMED_EVENT and never reach listeners.
153
+ if (event.type !== 'TestNotification' &&
154
+ typeof event.purchaseToken !== 'string') {
155
+ return {
156
+ kind: 'error',
157
+ message: `WebhookEvent missing required field purchaseToken`,
158
+ };
159
+ }
160
+ return { kind: 'ok', event };
161
+ }
162
+ function trimTrailingSlash(url) {
163
+ return url.endsWith('/') ? url.slice(0, -1) : url;
164
+ }
165
+ function defaultEventSourceFactory(url, _headers) {
166
+ // EventSource is part of the WHATWG spec and available in all
167
+ // browser environments and most JS runtimes (Bun, Node 22+, Deno).
168
+ // RN does not ship it natively — consumers must pass
169
+ // `eventSourceFactory` from `react-native-sse` or similar.
170
+ const ctor = globalThis.EventSource;
171
+ if (!ctor) {
172
+ throw new Error('EventSource is not defined. Pass `eventSourceFactory` for runtimes without a built-in EventSource.');
173
+ }
174
+ return new ctor(url);
175
+ }
176
+ //# sourceMappingURL=webhook-client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webhook-client.js","sourceRoot":"","sources":["../src/webhook-client.ts"],"names":[],"mappings":"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","sourcesContent":["// Transport-agnostic webhook client for the openiap kit SSE stream\n// (`GET /v1/webhooks/stream/{apiKey}`). Used by the JavaScript / TS\n// wrappers (react-native-iap, expo-iap) but written without React or\n// React-Native imports so it can also run in plain Node, browser, or\n// any other JS runtime.\n//\n// The wire format is documented in `packages/kit/server/api/v1/webhooks.ts`\n// and matches the GraphQL `WebhookEvent` shape from `webhook.graphql`.\n//\n// Parser logic is split out from the connection so it can be unit-\n// tested without a live server. See `webhook-client.test.ts`.\n\nexport type WebhookEventType =\n | 'SubscriptionStarted'\n | 'SubscriptionRenewed'\n | 'SubscriptionExpired'\n | 'SubscriptionInGracePeriod'\n | 'SubscriptionInBillingRetry'\n | 'SubscriptionRecovered'\n | 'SubscriptionCanceled'\n | 'SubscriptionUncanceled'\n | 'SubscriptionRevoked'\n | 'SubscriptionPriceChange'\n | 'SubscriptionProductChanged'\n | 'SubscriptionPaused'\n | 'SubscriptionResumed'\n | 'PurchaseRefunded'\n | 'PurchaseConsumptionRequest'\n | 'TestNotification';\n\nexport const WEBHOOK_EVENT_TYPES = [\n 'SubscriptionStarted',\n 'SubscriptionRenewed',\n 'SubscriptionExpired',\n 'SubscriptionInGracePeriod',\n 'SubscriptionInBillingRetry',\n 'SubscriptionRecovered',\n 'SubscriptionCanceled',\n 'SubscriptionUncanceled',\n 'SubscriptionRevoked',\n 'SubscriptionPriceChange',\n 'SubscriptionProductChanged',\n 'SubscriptionPaused',\n 'SubscriptionResumed',\n 'PurchaseRefunded',\n 'PurchaseConsumptionRequest',\n 'TestNotification',\n] as const satisfies readonly WebhookEventType[];\n\nexport type WebhookEventPayload = {\n id: string;\n type: WebhookEventType;\n source: string;\n platform: 'IOS' | 'Android';\n environment: 'Production' | 'Sandbox' | 'Xcode';\n projectId: string;\n occurredAt: number;\n receivedAt: number;\n // Optional because TestNotification frames carry no transaction;\n // every other event type populates this.\n purchaseToken?: string;\n productId?: string;\n subscriptionState?: string;\n expiresAt?: number;\n renewsAt?: number;\n cancellationReason?: string;\n currency?: string;\n priceAmountMicros?: number;\n rawSignedPayload?: string;\n};\n\nexport type WebhookListenerOptions = {\n /**\n * Project API key. Embedded in the URL path because Apple ASN\n * registration cannot send custom headers; the same path is reused\n * here for symmetry.\n */\n apiKey: string;\n /**\n * Override the kit base URL. Defaults to https://kit.openiap.dev.\n * In tests, point this at a local server.\n */\n baseUrl?: string;\n /** Called on every successfully-parsed webhook event. */\n onEvent: (event: WebhookEventPayload) => void;\n /**\n * Called on transport errors. The connection auto-reconnects\n * unconditionally; this callback exists for telemetry / surfacing\n * to the host UI.\n */\n onError?: (error: WebhookListenerError) => void;\n /**\n * Optional injection of an EventSource constructor. Lets RN /\n * Expo plug in `react-native-event-source` when running on a JS\n * runtime that lacks the global, or vitest plug in a stub.\n */\n eventSourceFactory?: (\n url: string,\n headers: Record<string, string>,\n ) => WebhookEventStream;\n};\n\nexport interface WebhookEventStream {\n close(): void;\n onmessage: ((event: {data: string; lastEventId?: string}) => void) | null;\n onerror: ((error: unknown) => void) | null;\n addEventListener?: (\n type: string,\n listener: (event: {data: string; lastEventId?: string}) => void,\n ) => void;\n}\n\nexport type WebhookListener = {\n /** Tear down the connection and stop receiving events. */\n close(): void;\n};\n\nexport type WebhookListenerError = {\n code:\n | 'TRANSPORT_ERROR'\n | 'PARSE_ERROR'\n | 'MALFORMED_EVENT'\n | 'NO_EVENTSOURCE';\n message: string;\n cause?: unknown;\n};\n\nconst DEFAULT_BASE_URL = 'https://kit.openiap.dev';\n\nexport function connectWebhookStream(\n options: WebhookListenerOptions,\n): WebhookListener {\n const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;\n const url = `${trimTrailingSlash(\n baseUrl,\n )}/v1/webhooks/stream/${encodeURIComponent(options.apiKey)}`;\n\n const factory = options.eventSourceFactory ?? defaultEventSourceFactory;\n let stream: WebhookEventStream;\n try {\n stream = factory(url, {});\n } catch (error) {\n options.onError?.({\n code: 'NO_EVENTSOURCE',\n message:\n error instanceof Error\n ? error.message\n : 'EventSource constructor unavailable in this runtime',\n cause: error,\n });\n return {close: () => {}};\n }\n\n const seenIds = new Set<string>();\n const seenOrder: string[] = [];\n const markSeen = (id: string): boolean => {\n if (seenIds.has(id)) {\n return true;\n }\n seenIds.add(id);\n seenOrder.push(id);\n if (seenOrder.length > 1024) {\n const evicted = seenOrder.shift();\n if (evicted !== undefined) {\n seenIds.delete(evicted);\n }\n }\n return false;\n };\n\n const handleData = (raw: string) => {\n const parsed = parseWebhookEventData(raw);\n if (parsed.kind === 'error') {\n options.onError?.({\n code: 'PARSE_ERROR',\n message: parsed.message,\n });\n return;\n }\n if (parsed.kind === 'skip') {\n return;\n }\n if (markSeen(parsed.event.id)) {\n return;\n }\n options.onEvent(parsed.event);\n };\n\n if (typeof stream.addEventListener === 'function') {\n stream.addEventListener('message', (event) => handleData(event.data));\n // WHATWG EventSource dispatches frames with `event: Foo` only to\n // listeners registered for `Foo`, not to `message` / `onmessage`.\n // Kit emits webhook frames as typed SSE events, so subscribe to\n // every known webhook type and keep `message` for older servers or\n // polyfills that collapse typed frames into the generic channel.\n for (const eventType of WEBHOOK_EVENT_TYPES) {\n stream.addEventListener(eventType, (event) => handleData(event.data));\n }\n } else {\n stream.onmessage = (event) => handleData(event.data);\n }\n\n stream.onerror = (error) => {\n options.onError?.({\n code: 'TRANSPORT_ERROR',\n message: 'SSE transport error (auto-reconnecting)',\n cause: error,\n });\n };\n\n return {\n close: () => {\n try {\n stream.close();\n } catch {\n // Closing an already-closed EventSource is a no-op in browsers\n // but throws in some polyfills.\n }\n },\n };\n}\n\n// ---------------------------------------------------------------------------\n// Pure helpers (exported for testing).\n// ---------------------------------------------------------------------------\n\nexport type ParsedEventResult =\n | {kind: 'ok'; event: WebhookEventPayload}\n | {kind: 'skip'; reason: 'heartbeat' | 'stream-control'}\n | {kind: 'error'; message: string};\n\nexport function parseWebhookEventData(raw: string): ParsedEventResult {\n if (!raw) {\n return {kind: 'skip', reason: 'heartbeat'};\n }\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch (error) {\n return {\n kind: 'error',\n message:\n error instanceof Error\n ? `Failed to parse SSE payload: ${error.message}`\n : 'Failed to parse SSE payload',\n };\n }\n\n if (\n typeof parsed !== 'object' ||\n parsed === null ||\n !('type' in parsed) ||\n typeof (parsed as Record<string, unknown>).type !== 'string'\n ) {\n // Stream-control messages (the `ready`/`stream-error` envelopes\n // emitted by the kit server) have no `type` and are surfaced as\n // skips so consumers don't see them as events.\n return {kind: 'skip', reason: 'stream-control'};\n }\n\n const event = parsed as WebhookEventPayload;\n\n if (\n typeof event.id !== 'string' ||\n typeof event.occurredAt !== 'number' ||\n typeof event.receivedAt !== 'number'\n ) {\n return {\n kind: 'error',\n message: `WebhookEvent missing required fields (id/occurredAt/receivedAt)`,\n };\n }\n // purchaseToken is required for every event type *except*\n // TestNotification — Apple ASN v2 / Google RTDN test payloads\n // carry no transaction. Hard-rejecting here would surface valid\n // test webhooks as MALFORMED_EVENT and never reach listeners.\n if (\n event.type !== 'TestNotification' &&\n typeof event.purchaseToken !== 'string'\n ) {\n return {\n kind: 'error',\n message: `WebhookEvent missing required field purchaseToken`,\n };\n }\n\n return {kind: 'ok', event};\n}\n\nfunction trimTrailingSlash(url: string): string {\n return url.endsWith('/') ? url.slice(0, -1) : url;\n}\n\nfunction defaultEventSourceFactory(\n url: string,\n _headers: Record<string, string>,\n): WebhookEventStream {\n // EventSource is part of the WHATWG spec and available in all\n // browser environments and most JS runtimes (Bun, Node 22+, Deno).\n // RN does not ship it natively — consumers must pass\n // `eventSourceFactory` from `react-native-sse` or similar.\n const ctor = (\n globalThis as {\n EventSource?: new (url: string) => WebhookEventStream;\n }\n ).EventSource;\n if (!ctor) {\n throw new Error(\n 'EventSource is not defined. Pass `eventSourceFactory` for runtimes without a built-in EventSource.',\n );\n }\n return new ctor(url);\n}\n"]}
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "spec": "2.0.1",
3
- "google": "2.1.1",
4
- "apple": "2.1.3"
3
+ "google": "2.1.2",
4
+ "apple": "2.1.5"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-iap",
3
- "version": "4.2.2",
3
+ "version": "4.2.4",
4
4
  "description": "In App Purchase module in Expo",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
package/src/index.ts CHANGED
@@ -331,18 +331,58 @@ export const subscriptionBillingIssueListener = (
331
331
  );
332
332
  };
333
333
 
334
+ /**
335
+ * Initialize the store connection. Must be called before any other IAP API.
336
+ *
337
+ * @param config Optional connection config. Use `enableBillingProgramAndroid` (Android,
338
+ * Play Billing 8.2.0+) to opt into External Payments etc. iOS ignores Android-specific fields.
339
+ * @returns Promise resolving to `true` when the platform billing client is connected.
340
+ * @throws When the platform billing client fails to initialize.
341
+ *
342
+ * @example
343
+ * ```ts
344
+ * await initConnection();
345
+ * await initConnection({ enableBillingProgramAndroid: 'external-offer' });
346
+ * ```
347
+ *
348
+ * @remarks When using `useIAP()`, connection is auto-managed on mount/unmount —
349
+ * pass options to the hook instead of calling this directly.
350
+ *
351
+ * @see {@link https://www.openiap.dev/docs/apis/init-connection}
352
+ */
334
353
  export const initConnection: MutationField<'initConnection'> = async (config) =>
335
354
  ExpoIapModule.initConnection(config ?? null);
336
355
 
356
+ /**
357
+ * Close the store connection and release resources.
358
+ *
359
+ * @see {@link https://www.openiap.dev/docs/apis/end-connection}
360
+ */
337
361
  export const endConnection: MutationField<'endConnection'> = async () =>
338
362
  ExpoIapModule.endConnection();
339
363
 
340
364
  /**
341
- * Fetch products with unified API (v2.7.0+)
365
+ * Retrieve products or subscriptions from the store by SKU.
366
+ *
367
+ * @param request `ProductRequest` — `skus` (string[]) and optional `type`
368
+ * (`'in-app' | 'subs' | 'all'`, defaults to `'in-app'`).
369
+ * @returns Promise resolving to a `FetchProductsResult` union — `Product[]` for `'in-app'`,
370
+ * `ProductSubscription[]` for `'subs'`, or a mixed array for `'all'`.
371
+ * @throws When the store rejects the request (empty `skus`, not connected,
372
+ * network/store error). Unknown SKUs are simply omitted from the result, not thrown.
342
373
  *
343
- * @param request - Product fetch configuration
344
- * @param request.skus - Array of product SKUs to fetch
345
- * @param request.type - Product query type: 'in-app', 'subs', or 'all'
374
+ * @example
375
+ * ```ts
376
+ * const products = await fetchProducts({
377
+ * skus: ['com.app.coins_100', 'com.app.premium'],
378
+ * type: 'in-app',
379
+ * });
380
+ * ```
381
+ *
382
+ * @remarks This is a regular promise-based call. Don't confuse with `request*` APIs
383
+ * (`requestPurchase`), which are event-based.
384
+ *
385
+ * @see {@link https://www.openiap.dev/docs/apis/fetch-products}
346
386
  */
347
387
  export const fetchProducts: QueryField<'fetchProducts'> = async (request) => {
348
388
  ExpoIapConsole.debug('fetchProducts called with:', request);
@@ -413,6 +453,25 @@ export const fetchProducts: QueryField<'fetchProducts'> = async (request) => {
413
453
  throw new Error('Unsupported platform');
414
454
  };
415
455
 
456
+ /**
457
+ * List the user's unfinished purchases — non-consumables, active subscriptions, and any
458
+ * pending transactions not yet finished.
459
+ *
460
+ * @param options Optional `PurchaseOptions`. iOS-only flags:
461
+ * `alsoPublishToEventListenerIOS`, `onlyIncludeActiveItemsIOS`.
462
+ * @returns Promise resolving to an array of `Purchase` currently held by the store.
463
+ * @throws When the platform query fails.
464
+ *
465
+ * @example
466
+ * ```ts
467
+ * const purchases = await getAvailablePurchases();
468
+ * for (const p of purchases) {
469
+ * if (await verifyOnServer(p)) await finishTransaction({ purchase: p, isConsumable: false });
470
+ * }
471
+ * ```
472
+ *
473
+ * @see {@link https://www.openiap.dev/docs/apis/get-available-purchases}
474
+ */
416
475
  export const getAvailablePurchases: QueryField<
417
476
  'getAvailablePurchases'
418
477
  > = async (options) => {
@@ -467,6 +526,8 @@ export const getAvailablePurchases: QueryField<
467
526
  * }
468
527
  * });
469
528
  * ```
529
+ *
530
+ * @see {@link https://www.openiap.dev/docs/apis/get-active-subscriptions}
470
531
  */
471
532
  export const getActiveSubscriptions: QueryField<
472
533
  'getActiveSubscriptions'
@@ -491,6 +552,8 @@ export const getActiveSubscriptions: QueryField<
491
552
  * // Check specific subscriptions
492
553
  * const hasPremium = await hasActiveSubscriptions(['premium', 'premium_year']);
493
554
  * ```
555
+ *
556
+ * @see {@link https://www.openiap.dev/docs/apis/has-active-subscriptions}
494
557
  */
495
558
  export const hasActiveSubscriptions: QueryField<
496
559
  'hasActiveSubscriptions'
@@ -500,6 +563,11 @@ export const hasActiveSubscriptions: QueryField<
500
563
  ));
501
564
  };
502
565
 
566
+ /**
567
+ * Return the user's storefront country code.
568
+ *
569
+ * @see {@link https://www.openiap.dev/docs/apis/get-storefront}
570
+ */
503
571
  export const getStorefront: QueryField<'getStorefront'> = async () => {
504
572
  if (Platform.OS !== 'ios' && Platform.OS !== 'android') {
505
573
  return '';
@@ -541,44 +609,30 @@ function normalizeRequestProps(
541
609
  }
542
610
 
543
611
  /**
544
- * Request a purchase for products or subscriptions.
612
+ * Initiate a purchase or subscription flow. The result is delivered through
613
+ * `purchaseUpdatedListener` — NOT the return value.
545
614
  *
546
- * @param requestObj - Purchase request configuration
547
- * @param requestObj.request - Store-specific purchase parameters
548
- * @param requestObj.type - Type of purchase: 'in-app' for products (default) or 'subs' for subscriptions
615
+ * @param args `RequestPurchaseProps`, discriminated by `type`:
616
+ * - `type: 'in-app'` — pass `request.apple.sku` (iOS) and/or `request.google.skus` (Android).
617
+ * - `type: 'subs'` — same shape, plus `request.google.subscriptionOffers: [{ sku, offerToken }]`.
618
+ * @returns The dispatched purchase payload. **Do not rely on it** for the actual outcome.
619
+ * @throws Synchronous rejection from the store (e.g. `E_NOT_PREPARED`, validation failure).
549
620
  *
550
621
  * @example
551
- * ```typescript
552
- * // Product purchase (recommended: use apple/google)
622
+ * ```ts
553
623
  * await requestPurchase({
554
624
  * request: {
555
- * apple: { sku: productId },
556
- * google: { skus: [productId] }
625
+ * apple: { sku: 'com.app.premium' },
626
+ * google: { skus: ['com.app.premium'] },
557
627
  * },
558
- * type: 'in-app'
628
+ * type: 'in-app',
559
629
  * });
630
+ * ```
560
631
  *
561
- * // Subscription purchase
562
- * await requestPurchase({
563
- * request: {
564
- * apple: { sku: subscriptionId },
565
- * google: {
566
- * skus: [subscriptionId],
567
- * subscriptionOffers: [{ sku: subscriptionId, offerToken: 'token' }]
568
- * }
569
- * },
570
- * type: 'subs'
571
- * });
632
+ * @remarks Event-based. Listen for the result via {@link purchaseUpdatedListener} /
633
+ * {@link purchaseErrorListener}, or use `useIAP({ onPurchaseSuccess, onPurchaseError })`.
572
634
  *
573
- * // Legacy format (deprecated, but still supported)
574
- * await requestPurchase({
575
- * request: {
576
- * ios: { sku: productId },
577
- * android: { skus: [productId] }
578
- * },
579
- * type: 'in-app'
580
- * });
581
- * ```
635
+ * @see {@link https://www.openiap.dev/docs/apis/request-purchase}
582
636
  */
583
637
  export const requestPurchase: MutationField<'requestPurchase'> = async (
584
638
  args,
@@ -739,6 +793,29 @@ export const requestPurchase: MutationField<'requestPurchase'> = async (
739
793
  throw new Error('Platform not supported');
740
794
  };
741
795
 
796
+ /**
797
+ * Complete a purchase transaction. Call after server-side verification to remove it
798
+ * from the queue.
799
+ *
800
+ * @param args.purchase The `Purchase` to finalize.
801
+ * @param args.isConsumable `true` for consumables (consumes the token so the SKU can be
802
+ * re-bought, e.g. coins); `false` (default) for non-consumables and subscriptions.
803
+ * @returns Promise that resolves once the platform finalizes the transaction.
804
+ * @throws When the platform finalize call fails.
805
+ *
806
+ * @example
807
+ * ```ts
808
+ * // Inside purchaseUpdatedListener:
809
+ * if (await verifyOnServer(purchase)) {
810
+ * await finishTransaction({ purchase, isConsumable: false });
811
+ * }
812
+ * ```
813
+ *
814
+ * @remarks **Critical:** Android purchases must be finalized within 3 days or Google
815
+ * auto-refunds. iOS unfinished transactions replay on every app launch.
816
+ *
817
+ * @see {@link https://www.openiap.dev/docs/apis/finish-transaction}
818
+ */
742
819
  export const finishTransaction: MutationField<'finishTransaction'> = async ({
743
820
  purchase,
744
821
  isConsumable = false,
@@ -781,6 +858,8 @@ export const finishTransaction: MutationField<'finishTransaction'> = async ({
781
858
  *
782
859
  * This helper triggers the refresh flows but does not return the purchases; consumers should
783
860
  * call `getAvailablePurchases` or rely on hook state to inspect the latest items.
861
+ *
862
+ * @see {@link https://www.openiap.dev/docs/apis/restore-purchases}
784
863
  */
785
864
  export const restorePurchases: MutationField<'restorePurchases'> = async () => {
786
865
  if (Platform.OS === 'ios') {
@@ -810,6 +889,8 @@ export const restorePurchases: MutationField<'restorePurchases'> = async () => {
810
889
  * skuAndroid: 'your_subscription_sku',
811
890
  * packageNameAndroid: 'com.example.app'
812
891
  * });
892
+ *
893
+ * @see {@link https://www.openiap.dev/docs/apis/deep-link-to-subscriptions}
813
894
  */
814
895
  export const deepLinkToSubscriptions: MutationField<
815
896
  'deepLinkToSubscriptions'
@@ -836,6 +917,8 @@ export const deepLinkToSubscriptions: MutationField<
836
917
  * - Android: Use Google Play Developer API with service account credentials
837
918
  *
838
919
  * @deprecated Use verifyPurchase instead
920
+ *
921
+ * @see {@link https://www.openiap.dev/docs/apis/validate-receipt}
839
922
  */
840
923
  export const validateReceipt: MutationField<'validateReceipt'> = async (
841
924
  options,
@@ -881,6 +964,8 @@ export const validateReceipt: MutationField<'validateReceipt'> = async (
881
964
  *
882
965
  * @param options - Receipt validation options containing the SKU
883
966
  * @returns Promise resolving to receipt validation result
967
+ *
968
+ * @see {@link https://www.openiap.dev/docs/features/validation#verify-purchase}
884
969
  */
885
970
  export const verifyPurchase: MutationField<'verifyPurchase'> = async (
886
971
  options,
@@ -916,6 +1001,8 @@ export const verifyPurchase: MutationField<'verifyPurchase'> = async (
916
1001
  * }
917
1002
  * });
918
1003
  * ```
1004
+ *
1005
+ * @see {@link https://www.openiap.dev/docs/features/validation#verify-purchase-with-provider}
919
1006
  */
920
1007
  export const verifyPurchaseWithProvider: MutationField<
921
1008
  'verifyPurchaseWithProvider'
@@ -955,6 +1042,27 @@ export const verifyPurchaseWithProvider: MutationField<
955
1042
  };
956
1043
 
957
1044
  export * from './useIAP';
1045
+ export {useWebhookEvents} from './useWebhookEvents';
1046
+ export type {
1047
+ UseWebhookEventsOptions,
1048
+ UseWebhookEventsResult,
1049
+ } from './useWebhookEvents';
1050
+ export {connectWebhookStream, parseWebhookEventData} from './webhook-client';
1051
+ export type {
1052
+ WebhookEventPayload,
1053
+ WebhookEventStream,
1054
+ WebhookEventType as WebhookEventTypeName,
1055
+ WebhookListener,
1056
+ WebhookListenerError,
1057
+ WebhookListenerOptions,
1058
+ } from './webhook-client';
1059
+ export {kitApi, KitApiError} from './kit-api';
1060
+ export type {
1061
+ KitApiOptions,
1062
+ KitSubscription,
1063
+ EntitlementsResponse,
1064
+ StatusResponse,
1065
+ } from './kit-api';
958
1066
  export {
959
1067
  ErrorCodeUtils,
960
1068
  ErrorCodeMapping,