Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 100 additions & 10 deletions packages/apple/Sources/Models/Types.swift
Original file line number Diff line number Diff line change
Expand Up @@ -844,6 +844,15 @@ public struct VerifyPurchaseResultAndroid: Codable {
public var testTransaction: Bool
}

/// Result from Meta Horizon verify_entitlement API.
/// Returns verification status and grant time for the entitlement.
public struct VerifyPurchaseResultHorizon: Codable {
/// Unix timestamp (seconds) when the entitlement was granted.
public var grantTime: Double?
/// Whether the entitlement verification succeeded.
public var success: Bool
}

public struct VerifyPurchaseResultIOS: Codable {
/// Whether the receipt is valid
public var isValid: Bool
Expand Down Expand Up @@ -1142,6 +1151,12 @@ public struct RequestPurchaseProps: Codable {
}
}

/// Platform-specific purchase request parameters.
///
/// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store.
/// - apple: Always targets App Store
/// - google: Targets Play Store by default, or Horizon when built with horizon flavor
/// (determined at build time, not runtime)
public struct RequestPurchasePropsByPlatforms: Codable {
/// @deprecated Use google instead
public var android: RequestPurchaseAndroidProps?
Expand Down Expand Up @@ -1228,6 +1243,12 @@ public struct RequestSubscriptionIosProps: Codable {
}
}

/// Platform-specific subscription request parameters.
///
/// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store.
/// - apple: Always targets App Store
/// - google: Targets Play Store by default, or Horizon when built with horizon flavor
/// (determined at build time, not runtime)
public struct RequestSubscriptionPropsByPlatforms: Codable {
/// @deprecated Use google instead
public var android: RequestSubscriptionAndroidProps?
Expand Down Expand Up @@ -1273,12 +1294,16 @@ public struct RequestVerifyPurchaseWithIapkitGoogleProps: Codable {
}
}

/// Platform-specific verification parameters for IAPKit.
///
/// - apple: Verifies via App Store (JWS token)
/// - google: Verifies via Play Store (purchase token)
public struct RequestVerifyPurchaseWithIapkitProps: Codable {
/// API key used for the Authorization header (Bearer {apiKey}).
public var apiKey: String?
/// Apple verification parameters.
/// Apple App Store verification parameters.
public var apple: RequestVerifyPurchaseWithIapkitAppleProps?
/// Google verification parameters.
/// Google Play Store verification parameters.
public var google: RequestVerifyPurchaseWithIapkitGoogleProps?

public init(
Expand Down Expand Up @@ -1310,36 +1335,100 @@ public struct SubscriptionProductReplacementParamsAndroid: Codable {
}
}

public struct VerifyPurchaseAndroidOptions: Codable {
/// Apple App Store verification parameters.
/// Used for server-side receipt validation via App Store Server API.
///
/// ⚠️ SECURITY: Contains sensitive token (jws). Do not log or persist this data.
public struct VerifyPurchaseAppleOptions: Codable {
/// The JWS (JSON Web Signature) representation of the transaction.
/// ⚠️ Sensitive: Do not log this value.
public var jws: String

public init(
jws: String
) {
self.jws = jws
}
}

/// Google Play Store verification parameters.
/// Used for server-side receipt validation via Google Play Developer API.
///
/// ⚠️ SECURITY: Contains sensitive tokens (accessToken, purchaseToken). Do not log or persist this data.
public struct VerifyPurchaseGoogleOptions: Codable {
/// Google OAuth2 access token for API authentication.
/// ⚠️ Sensitive: Do not log this value.
public var accessToken: String
/// Whether this is a subscription purchase (affects API endpoint used)
public var isSub: Bool?
/// Android package name (e.g., com.example.app)
public var packageName: String
public var productToken: String
/// Purchase token from the purchase response.
/// ⚠️ Sensitive: Do not log this value.
public var purchaseToken: String

public init(
accessToken: String,
isSub: Bool? = nil,
packageName: String,
productToken: String
purchaseToken: String
) {
self.accessToken = accessToken
self.isSub = isSub
self.packageName = packageName
self.productToken = productToken
self.purchaseToken = purchaseToken
}
}

/// Meta Horizon (Quest) verification parameters.
/// Used for server-side entitlement verification via Meta's S2S API.
/// POST https://graph.oculus.com/$APP_ID/verify_entitlement
///
/// ⚠️ SECURITY: Contains sensitive token (accessToken). Do not log or persist this data.
public struct VerifyPurchaseHorizonOptions: Codable {
/// Access token for Meta API authentication (OC|$APP_ID|$APP_SECRET or User Access Token).
/// ⚠️ Sensitive: Do not log this value.
public var accessToken: String
/// The SKU for the add-on item, defined in Meta Developer Dashboard
public var sku: String
/// The user ID of the user whose purchase you want to verify
public var userId: String

public init(
accessToken: String,
sku: String,
userId: String
) {
self.accessToken = accessToken
self.sku = sku
self.userId = userId
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Platform-specific purchase verification parameters.
///
/// - apple: Verifies via App Store Server API
/// - google: Verifies via Google Play Developer API
/// - horizon: Verifies via Meta's S2S API (verify_entitlement endpoint)
public struct VerifyPurchaseProps: Codable {
/// Android-specific validation options
public var androidOptions: VerifyPurchaseAndroidOptions?
/// Apple App Store verification parameters.
public var apple: VerifyPurchaseAppleOptions?
/// Google Play Store verification parameters.
public var google: VerifyPurchaseGoogleOptions?
/// Meta Horizon (Quest) verification parameters.
public var horizon: VerifyPurchaseHorizonOptions?
/// Product SKU to validate
public var sku: String

public init(
androidOptions: VerifyPurchaseAndroidOptions? = nil,
apple: VerifyPurchaseAppleOptions? = nil,
google: VerifyPurchaseGoogleOptions? = nil,
horizon: VerifyPurchaseHorizonOptions? = nil,
sku: String
) {
self.androidOptions = androidOptions
self.apple = apple
self.google = google
self.horizon = horizon
self.sku = sku
}
}
Expand Down Expand Up @@ -1667,6 +1756,7 @@ public enum Purchase: Codable, PurchaseCommon {
public enum VerifyPurchaseResult: Codable {
case verifyPurchaseResultAndroid(VerifyPurchaseResultAndroid)
case verifyPurchaseResultIos(VerifyPurchaseResultIOS)
case verifyPurchaseResultHorizon(VerifyPurchaseResultHorizon)
}

// MARK: - Root Operations
Expand Down
27 changes: 18 additions & 9 deletions packages/apple/Sources/OpenIapModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -598,16 +598,25 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol {
var jws: String = ""
var isValid = false

do {
let product = try await storeProduct(for: props.sku)
if let result = await product.latestTransaction {
jws = result.jwsRepresentation
let transaction = try checkVerified(result)
latestPurchase = .purchaseIos(await StoreKitTypesBridge.purchaseIOS(from: transaction, jwsRepresentation: result.jwsRepresentation))
isValid = true
// If apple options with JWS are provided, use that directly
// Otherwise, fetch the latest transaction from StoreKit
if let appleOptions = props.apple, !appleOptions.jws.isEmpty {
jws = appleOptions.jws
// When JWS is provided externally, we trust it's valid
// The caller should verify the JWS on their server
isValid = true
} else {
do {
let product = try await storeProduct(for: props.sku)
if let result = await product.latestTransaction {
jws = result.jwsRepresentation
let transaction = try checkVerified(result)
latestPurchase = .purchaseIos(await StoreKitTypesBridge.purchaseIOS(from: transaction, jwsRepresentation: result.jwsRepresentation))
isValid = true
}
} catch {
isValid = false
}
} catch {
isValid = false
}

return VerifyPurchaseResultIOS(
Expand Down
85 changes: 85 additions & 0 deletions packages/docs/src/pages/docs/updates/notes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,91 @@ function Notes() {
<section>
<h2>📝 API & Terminology Changes</h2>

<div
style={{
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: '0.5rem',
padding: '1rem',
marginBottom: '1.5rem',
}}
>
<h4 style={{ marginTop: 0, color: 'var(--text-primary)' }}>
📅 openiap-gql v1.3.3 / openiap-google v1.3.13 / openiap-apple v1.3.1
- Platform-Specific Verification Options
</h4>
<p>
<strong>verifyPurchase API Refactored:</strong>
</p>
<p>
The <code>verifyPurchase</code> API now supports platform-specific
options for Apple, Google, and Meta Horizon stores.
</p>
<ul>
<li>
<strong>
<code>VerifyPurchaseAppleOptions</code>
</strong>{' '}
- Apple App Store verification with JWS token
</li>
<li>
<strong>
<code>VerifyPurchaseGoogleOptions</code>
</strong>{' '}
- Google Play verification with packageName, purchaseToken, and
accessToken
</li>
<li>
<strong>
<code>VerifyPurchaseHorizonOptions</code>
</strong>{' '}
- Meta Horizon (Quest) verification via S2S API with sku, userId,
and accessToken
</li>
</ul>
<p>
<strong>New VerifyPurchaseProps Structure:</strong>
</p>
<CodeBlock language="typescript">
{`// Platform-specific verification (recommended)
verifyPurchase({
sku: 'premium_monthly',
apple: { jws: 'eyJ...' }, // iOS App Store
google: { // Google Play
packageName: 'com.example.app',
purchaseToken: 'token...',
accessToken: 'oauth_token...',
isSub: true
},
horizon: { // Meta Quest
sku: '50_gems',
userId: '123456789',
accessToken: 'OC|app_id|app_secret'
}
})

// Legacy format still supported (deprecated)
verifyPurchase({
sku: 'premium_monthly',
androidOptions: { ... } // @deprecated - use google instead
})`}
</CodeBlock>
<p>
<strong>Deprecations:</strong>
</p>
<ul>
<li>
<code>androidOptions</code> in VerifyPurchaseProps → Use{' '}
<code>google</code> instead
</li>
</ul>
<p>
See:{' '}
<a href="/docs/apis#verify-purchase">verifyPurchase API</a>,{' '}
<a href="/docs/types#verify-purchase-props">VerifyPurchaseProps</a>
</p>
</div>

<div
style={{
background: 'var(--bg-secondary)',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import dev.hyo.openiap.utils.HorizonBillingConverters.toPurchase
import dev.hyo.openiap.utils.HorizonBillingConverters.toSubscriptionProduct
import dev.hyo.openiap.utils.toProduct
import dev.hyo.openiap.utils.verifyPurchaseWithGooglePlay
import dev.hyo.openiap.utils.verifyPurchaseWithHorizon
import dev.hyo.openiap.MutationVerifyPurchaseHandler
import dev.hyo.openiap.MutationValidateReceiptHandler
import dev.hyo.openiap.MutationVerifyPurchaseWithProviderHandler
Expand Down Expand Up @@ -654,7 +655,17 @@ class OpenIapModule(
}

override val verifyPurchase: MutationVerifyPurchaseHandler = { props ->
verifyPurchaseWithGooglePlay(props, TAG)
// Use Horizon API if horizon options provided, otherwise fallback to Google Play
if (props.horizon != null) {
val horizonAppId = appId ?: throw OpenIapError.DeveloperError
val horizonResult = verifyPurchaseWithHorizon(props, horizonAppId, TAG)
if (!horizonResult.success) {
throw OpenIapError.InvalidPurchaseVerification
}
horizonResult
} else {
verifyPurchaseWithGooglePlay(props, TAG)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

override val verifyPurchaseWithProvider: MutationVerifyPurchaseWithProviderHandler = { props ->
Expand Down
Loading
Loading