Skip to content

Commit 8d6e9b7

Browse files
hyochanclaude
andauthored
feat(ios): add ExternalPurchaseCustomLink API support (iOS 18.1+) (#311)
## Summary - Sync with OpenIAP v1.3.16 (gql: 1.3.16, apple: 1.3.14, google: 1.3.27) - Add 3 new ExternalPurchaseCustomLink APIs for iOS 18.1+: - `isEligibleForExternalPurchaseCustomLinkIOS()` - Check eligibility - `getExternalPurchaseCustomLinkTokenIOS(tokenType)` - Get token for Apple reporting - `showExternalPurchaseCustomLinkNoticeIOS(noticeType)` - Show disclosure notice - Update `presentExternalPurchaseNoticeSheetIOS` to return `externalPurchaseToken` ## Changes | File | Description | |------|-------------| | openiap-versions.json | Update to gql 1.3.16, apple 1.3.14, google 1.3.27 | | src/types.ts | Regenerated types with new ExternalPurchaseCustomLink types | | ios/ExpoIapModule.swift | Add 3 new AsyncFunctions | | src/modules/ios.ts | Add TypeScript API wrappers | | docs/blog/2026-01-26-3.4.7-external-purchase-custom-link.md | Release notes | | docs/static/llms.txt | Update AI reference | | docs/static/llms-full.txt | Update AI reference | ## Test plan - [x] `bun run lint:ci` passes - [x] `bun run test` passes (186 tests) - [x] TypeScript types compile correctly ## References - [Apple ExternalPurchaseCustomLink Documentation](https://developer.apple.com/documentation/storekit/externalpurchasecustomlink) - [OpenIAP Release Notes](https://www.openiap.dev/docs/updates/notes#gql-1-3-16-apple-1-3-14) 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * iOS 18.1+ External Purchase Custom Link flow: eligibility check, disclosure notice flow, token retrieval for Apple reporting. * External purchase notice flow now returns a reporting token when the user continues. * **Documentation** * Added detailed release notes, usage example, and installation/version guidance for the new flow. * **Tests** * Added comprehensive tests covering the new iOS external purchase APIs and flows. <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 03e6120 commit 8d6e9b7

File tree

8 files changed

+770
-18
lines changed

8 files changed

+770
-18
lines changed
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
---
2+
slug: 3.4.7
3+
title: 3.4.7 - ExternalPurchaseCustomLink API (iOS 18.1+)
4+
authors: [hyochan]
5+
tags: [release, ios, storekit, external-purchase, ios-18]
6+
date: 2026-01-26
7+
---
8+
9+
# 3.4.7 Release Notes
10+
11+
Expo IAP 3.4.7 adds support for Apple's **ExternalPurchaseCustomLink** API (iOS 18.1+) for apps using custom external purchase links.
12+
13+
<!-- truncate -->
14+
15+
## New Features
16+
17+
### ExternalPurchaseCustomLink API (iOS 18.1+)
18+
19+
The ExternalPurchaseCustomLink API enables apps to use custom external purchase links with token-based reporting to Apple. This is for apps that have been granted an entitlement to link out to external purchases.
20+
21+
Reference: [ExternalPurchaseCustomLink Documentation](https://developer.apple.com/documentation/storekit/externalpurchasecustomlink)
22+
23+
### New APIs
24+
25+
#### `isEligibleForExternalPurchaseCustomLinkIOS()`
26+
27+
Check if your app is eligible to use the ExternalPurchaseCustomLink API.
28+
29+
```tsx
30+
import {isEligibleForExternalPurchaseCustomLinkIOS} from 'expo-iap';
31+
32+
const isEligible = await isEligibleForExternalPurchaseCustomLinkIOS();
33+
if (isEligible) {
34+
// App can use custom external purchase links
35+
}
36+
```
37+
38+
#### `getExternalPurchaseCustomLinkTokenIOS(tokenType)`
39+
40+
Get an external purchase token for reporting to Apple's External Purchase Server API.
41+
42+
```tsx
43+
import {getExternalPurchaseCustomLinkTokenIOS} from 'expo-iap';
44+
45+
// For new customer acquisition
46+
const acquisitionResult = await getExternalPurchaseCustomLinkTokenIOS('acquisition');
47+
if (acquisitionResult.token) {
48+
// Report to Apple's External Purchase Server API
49+
await reportToApple(acquisitionResult.token);
50+
}
51+
52+
// For existing customer services
53+
const servicesResult = await getExternalPurchaseCustomLinkTokenIOS('services');
54+
if (servicesResult.token) {
55+
await reportToApple(servicesResult.token);
56+
}
57+
```
58+
59+
#### `showExternalPurchaseCustomLinkNoticeIOS(noticeType)`
60+
61+
Display the system disclosure notice sheet before linking out to external purchases.
62+
63+
```tsx
64+
import {showExternalPurchaseCustomLinkNoticeIOS} from 'expo-iap';
65+
66+
const result = await showExternalPurchaseCustomLinkNoticeIOS('browser');
67+
if (result.continued) {
68+
// User agreed to continue to external purchase
69+
// Now open your external purchase link
70+
await Linking.openURL('https://your-store.com/checkout');
71+
} else {
72+
// User cancelled
73+
}
74+
```
75+
76+
### Updated: `presentExternalPurchaseNoticeSheetIOS()`
77+
78+
Now returns `externalPurchaseToken` when user continues to external purchase.
79+
80+
```tsx
81+
import {presentExternalPurchaseNoticeSheetIOS} from 'expo-iap';
82+
83+
const result = await presentExternalPurchaseNoticeSheetIOS();
84+
if (result.result === 'continue') {
85+
console.log('Token:', result.externalPurchaseToken);
86+
// Report this token to Apple's External Purchase Server API
87+
}
88+
```
89+
90+
### New Types
91+
92+
```ts
93+
// Token types for getExternalPurchaseCustomLinkTokenIOS
94+
type ExternalPurchaseCustomLinkTokenTypeIOS = 'acquisition' | 'services';
95+
96+
// Notice types for showExternalPurchaseCustomLinkNoticeIOS
97+
type ExternalPurchaseCustomLinkNoticeTypeIOS = 'browser';
98+
99+
// Result of token retrieval
100+
interface ExternalPurchaseCustomLinkTokenResultIOS {
101+
token?: string | null;
102+
error?: string | null;
103+
}
104+
105+
// Result of showing notice
106+
interface ExternalPurchaseCustomLinkNoticeResultIOS {
107+
continued: boolean;
108+
error?: string | null;
109+
}
110+
111+
// Updated ExternalPurchaseNoticeResultIOS
112+
interface ExternalPurchaseNoticeResultIOS {
113+
result: 'continue' | 'dismissed';
114+
externalPurchaseToken?: string | null; // New field
115+
error?: string | null;
116+
}
117+
```
118+
119+
### Complete External Purchase Custom Link Flow
120+
121+
```tsx
122+
import {useEffect, useState} from 'react';
123+
import {Button, Linking, Platform} from 'react-native';
124+
import {
125+
isEligibleForExternalPurchaseCustomLinkIOS,
126+
getExternalPurchaseCustomLinkTokenIOS,
127+
showExternalPurchaseCustomLinkNoticeIOS,
128+
} from 'expo-iap';
129+
130+
export function ExternalPurchaseCustomLink() {
131+
const [isEligible, setIsEligible] = useState(false);
132+
133+
useEffect(() => {
134+
if (Platform.OS !== 'ios') return;
135+
136+
isEligibleForExternalPurchaseCustomLinkIOS().then(setIsEligible);
137+
}, []);
138+
139+
const handleExternalPurchase = async () => {
140+
// Step 1: Show disclosure notice
141+
const noticeResult = await showExternalPurchaseCustomLinkNoticeIOS('browser');
142+
if (!noticeResult.continued) {
143+
console.log('User cancelled');
144+
return;
145+
}
146+
147+
// Step 2: Get token for reporting
148+
const tokenResult = await getExternalPurchaseCustomLinkTokenIOS('acquisition');
149+
if (tokenResult.error) {
150+
console.error('Failed to get token:', tokenResult.error);
151+
return;
152+
}
153+
154+
// Step 3: Open external purchase link
155+
await Linking.openURL('https://your-store.com/checkout');
156+
157+
// Step 4: After purchase completes, report to Apple
158+
if (tokenResult.token) {
159+
await reportExternalPurchaseToApple(tokenResult.token);
160+
}
161+
};
162+
163+
if (!isEligible) return null;
164+
165+
return (
166+
<Button title="Purchase on Web" onPress={handleExternalPurchase} />
167+
);
168+
}
169+
```
170+
171+
## OpenIAP Updates
172+
173+
| Package | Version |
174+
|---------|---------|
175+
| openiap-gql | 1.3.16 |
176+
| openiap-apple | 1.3.14 |
177+
| openiap-google | 1.3.27 |
178+
179+
## Installation
180+
181+
```bash
182+
bun add expo-iap@3.4.7
183+
# or
184+
npm install expo-iap@3.4.7
185+
# or
186+
yarn add expo-iap@3.4.7
187+
```
188+
189+
## References
190+
191+
- [Apple ExternalPurchaseCustomLink Documentation](https://developer.apple.com/documentation/storekit/externalpurchasecustomlink)
192+
- [OpenIAP Release Notes](https://www.openiap.dev/docs/updates/notes#gql-1-3-16-apple-1-3-14)
193+
194+
Questions or feedback? Reach out via [GitHub issues](https://github.com/hyochan/expo-iap/issues).

docs/static/llms-full.txt

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -742,10 +742,14 @@ import {
742742
currentEntitlementIOS,
743743
showManageSubscriptionsIOS,
744744
beginRefundRequestIOS,
745-
// Alternative Billing (iOS 16+)
745+
// External Purchase (iOS 15.4+)
746746
canPresentExternalPurchaseNoticeIOS,
747747
presentExternalPurchaseNoticeSheetIOS,
748748
presentExternalPurchaseLinkIOS,
749+
// ExternalPurchaseCustomLink (iOS 18.1+)
750+
isEligibleForExternalPurchaseCustomLinkIOS,
751+
getExternalPurchaseCustomLinkTokenIOS,
752+
showExternalPurchaseCustomLinkNoticeIOS,
749753
} from 'expo-iap';
750754

751755
// Clear pending transactions
@@ -759,6 +763,25 @@ await beginRefundRequestIOS(transactionId);
759763

760764
// Show subscription management
761765
await showManageSubscriptionsIOS();
766+
767+
// ExternalPurchaseCustomLink (iOS 18.1+)
768+
// Check if eligible for custom external purchase links
769+
const isEligible = await isEligibleForExternalPurchaseCustomLinkIOS();
770+
771+
if (isEligible) {
772+
// Show disclosure notice before external link
773+
const noticeResult = await showExternalPurchaseCustomLinkNoticeIOS('browser');
774+
if (noticeResult.continued) {
775+
// Get token for reporting to Apple
776+
const tokenResult = await getExternalPurchaseCustomLinkTokenIOS('acquisition');
777+
// tokenResult.token - report to Apple's External Purchase Server API
778+
// tokenResult.error - error message if failed
779+
}
780+
}
781+
782+
// presentExternalPurchaseNoticeSheetIOS now returns token
783+
const result = await presentExternalPurchaseNoticeSheetIOS();
784+
// result.externalPurchaseToken - token to report to Apple
762785
```
763786

764787
### Android-Specific

docs/static/llms.txt

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,33 @@ const restore = async () => {
337337
await getAvailablePurchases({includeSuspendedAndroid: true});
338338
```
339339

340+
### iOS External Purchase Custom Link (iOS 18.1+)
341+
342+
For apps with custom external purchase links entitlement:
343+
344+
```tsx
345+
import {
346+
isEligibleForExternalPurchaseCustomLinkIOS,
347+
getExternalPurchaseCustomLinkTokenIOS,
348+
showExternalPurchaseCustomLinkNoticeIOS,
349+
} from 'expo-iap';
350+
351+
// Check eligibility
352+
const isEligible = await isEligibleForExternalPurchaseCustomLinkIOS();
353+
354+
if (isEligible) {
355+
// Show disclosure notice
356+
const notice = await showExternalPurchaseCustomLinkNoticeIOS('browser');
357+
if (notice.continued) {
358+
// Get token for reporting to Apple
359+
const result = await getExternalPurchaseCustomLinkTokenIOS('acquisition');
360+
if (result.token) {
361+
// Open external link and report to Apple's External Purchase Server API
362+
}
363+
}
364+
}
365+
```
366+
340367
### Check Active Subscriptions
341368

342369
```tsx

ios/ExpoIapModule.swift

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,5 +415,42 @@ public final class ExpoIapModule: Module {
415415
ExpoIapLog.result("getAppTransactionIOS", value: nil)
416416
return nil
417417
}
418+
419+
// MARK: - ExternalPurchaseCustomLink (iOS 18.1+)
420+
421+
AsyncFunction("isEligibleForExternalPurchaseCustomLinkIOS") { () async throws -> Bool in
422+
ExpoIapLog.payload("isEligibleForExternalPurchaseCustomLinkIOS", payload: nil)
423+
let isEligible = try await OpenIapModule.shared.isEligibleForExternalPurchaseCustomLinkIOS()
424+
ExpoIapLog.result("isEligibleForExternalPurchaseCustomLinkIOS", value: isEligible)
425+
return isEligible
426+
}
427+
428+
AsyncFunction("getExternalPurchaseCustomLinkTokenIOS") { (tokenType: String) async throws -> [String: Any] in
429+
ExpoIapLog.payload("getExternalPurchaseCustomLinkTokenIOS", payload: ["tokenType": tokenType])
430+
guard let type = ExternalPurchaseCustomLinkTokenTypeIOS(rawValue: tokenType) else {
431+
throw IapException.from(PurchaseError.make(
432+
code: .developerError,
433+
message: "Invalid token type: \(tokenType). Must be 'acquisition' or 'services'"
434+
))
435+
}
436+
let result = try await OpenIapModule.shared.getExternalPurchaseCustomLinkTokenIOS(type)
437+
let sanitized = ExpoIapHelper.sanitizeDictionary(OpenIapSerialization.encode(result))
438+
ExpoIapLog.result("getExternalPurchaseCustomLinkTokenIOS", value: sanitized)
439+
return sanitized
440+
}
441+
442+
AsyncFunction("showExternalPurchaseCustomLinkNoticeIOS") { (noticeType: String) async throws -> [String: Any] in
443+
ExpoIapLog.payload("showExternalPurchaseCustomLinkNoticeIOS", payload: ["noticeType": noticeType])
444+
guard let type = ExternalPurchaseCustomLinkNoticeTypeIOS(rawValue: noticeType) else {
445+
throw IapException.from(PurchaseError.make(
446+
code: .developerError,
447+
message: "Invalid notice type: \(noticeType). Must be 'browser'"
448+
))
449+
}
450+
let result = try await OpenIapModule.shared.showExternalPurchaseCustomLinkNoticeIOS(type)
451+
let sanitized = ExpoIapHelper.sanitizeDictionary(OpenIapSerialization.encode(result))
452+
ExpoIapLog.result("showExternalPurchaseCustomLinkNoticeIOS", value: sanitized)
453+
return sanitized
454+
}
418455
}
419456
}

openiap-versions.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"apple": "1.3.13",
2+
"apple": "1.3.14",
33
"google": "1.3.27",
4-
"gql": "1.3.15"
4+
"gql": "1.3.16"
55
}

0 commit comments

Comments
 (0)