Skip to content
This repository was archived by the owner on Apr 26, 2026. It is now read-only.
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
72 changes: 72 additions & 0 deletions docs/blog/2026-02-11-release-1.3.7.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
---
slug: 1.3.7
title: 1.3.7 - OpenIAP 1.3.17 Sync
authors: [hyochan]
tags: [release, openiap, android, billing-library]
date: 2026-02-11
Comment thread
hyochan marked this conversation as resolved.
---

# 1.3.7 Release Notes

This release syncs with [OpenIAP v1.3.17](https://www.openiap.dev/docs/updates/notes#gql-1317), adding new types for Google Play Billing Library 5.0+ and 7.0+ features.

<!-- truncate -->

## New Types

### InstallmentPlanDetailsAndroid (Billing Library 7.0+)

Subscription installment plan details for plans that allow users to pay in installments.

```kotlin
data class InstallmentPlanDetailsAndroid(
/** Committed payments count after signup (e.g., 12 monthly payments) */
val commitmentPaymentsCount: Int,
/** Subsequent commitment payments when plan renews (0 if reverts to normal) */
val subsequentCommitmentPaymentsCount: Int
)
```

This is available on `ProductSubscriptionAndroidOfferDetails.installmentPlanDetails`.

### PendingPurchaseUpdateAndroid (Billing Library 5.0+)

Details about pending subscription upgrades/downgrades.

```kotlin
data class PendingPurchaseUpdateAndroid(
/** Product IDs for the pending purchase update */
val products: List<String>,
/** Purchase token for the pending transaction */
val purchaseToken: String
)
```

This is available on `PurchaseAndroid.pendingPurchaseUpdateAndroid`.

### purchaseOptionIdAndroid (Billing Library 7.0+)

New field on `DiscountOffer` and `ProductAndroidOneTimePurchaseOfferDetail` to identify which purchase option the user selected.

```kotlin
// Available on DiscountOffer
discountOffer.purchaseOptionIdAndroid // String?
```

## OpenIAP Versions

| Package | Version |
|---------|---------|
| openiap-gql | 1.3.17 |
| openiap-google | 1.3.28 |
| openiap-apple | 1.3.14 |

## Installation

```kotlin
implementation("io.github.hyochan:kmp-iap:1.3.7")
```

## Related

- [OpenIAP Release Notes](https://www.openiap.dev/docs/updates/notes#gql-1317)
15 changes: 14 additions & 1 deletion docs/static/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,20 @@ data class DiscountOffer(
// Android-specific
val offerTokenAndroid: String?,
val percentageDiscountAndroid: Int?,
val formattedDiscountAmountAndroid: String?
val formattedDiscountAmountAndroid: String?,
val purchaseOptionIdAndroid: String? // v1.3.7+, Billing Library 7.0+
)

// Installment plan details (v1.3.7+, Billing Library 7.0+)
data class InstallmentPlanDetailsAndroid(
val commitmentPaymentsCount: Int, // Initial commitment (e.g., 12 months)
val subsequentCommitmentPaymentsCount: Int // Renewal commitment (0 if reverts to normal)
)

// Pending subscription update (v1.3.7+, Billing Library 5.0+)
data class PendingPurchaseUpdateAndroid(
val products: List<String>, // New products being switched to
val purchaseToken: String // Pending transaction token
)
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1486,6 +1486,12 @@ public data class DiscountOffer(
* Numeric price value
*/
val price: Double,
/**
* [Android] Purchase option ID for this offer.
* Used to identify which purchase option the user selected.
* Available in Google Play Billing Library 7.0+
*/
val purchaseOptionIdAndroid: String? = null,
/**
* [Android] Rental details if this is a rental offer.
*/
Expand Down Expand Up @@ -1516,6 +1522,7 @@ public data class DiscountOffer(
percentageDiscountAndroid = (json["percentageDiscountAndroid"] as? Number)?.toInt(),
preorderDetailsAndroid = (json["preorderDetailsAndroid"] as? Map<String, Any?>)?.let { PreorderDetailsAndroid.fromJson(it) },
price = (json["price"] as? Number)?.toDouble() ?: 0.0,
purchaseOptionIdAndroid = json["purchaseOptionIdAndroid"] as? String,
rentalDetailsAndroid = (json["rentalDetailsAndroid"] as? Map<String, Any?>)?.let { RentalDetailsAndroid.fromJson(it) },
type = (json["type"] as? String)?.let { DiscountOfferType.fromJson(it) } ?: DiscountOfferType.Introductory,
validTimeWindowAndroid = (json["validTimeWindowAndroid"] as? Map<String, Any?>)?.let { ValidTimeWindowAndroid.fromJson(it) },
Expand All @@ -1537,6 +1544,7 @@ public data class DiscountOffer(
"percentageDiscountAndroid" to percentageDiscountAndroid,
"preorderDetailsAndroid" to preorderDetailsAndroid?.toJson(),
"price" to price,
"purchaseOptionIdAndroid" to purchaseOptionIdAndroid,
"rentalDetailsAndroid" to rentalDetailsAndroid?.toJson(),
"type" to type.toJson(),
"validTimeWindowAndroid" to validTimeWindowAndroid?.toJson(),
Expand Down Expand Up @@ -1731,7 +1739,7 @@ public data class ExternalPurchaseCustomLinkTokenResultIOS(
}

/**
* Result of presenting an external purchase link (iOS 17.4+)
* Result of presenting an external purchase link
*/
public data class ExternalPurchaseLinkResultIOS(
/**
Expand Down Expand Up @@ -1807,6 +1815,43 @@ public data class FetchProductsResultProducts(val value: List<Product>?) : Fetch

public data class FetchProductsResultSubscriptions(val value: List<ProductSubscription>?) : FetchProductsResult

/**
* Installment plan details for subscription offers (Android)
* Contains information about the installment plan commitment.
* Available in Google Play Billing Library 7.0+
*/
public data class InstallmentPlanDetailsAndroid(
/**
* Committed payments count after a user signs up for this subscription plan.
* For example, for a monthly subscription with commitmentPaymentsCount of 12,
* users will be charged monthly for 12 months after signup.
*/
val commitmentPaymentsCount: Int,
/**
* Subsequent committed payments count after the subscription plan renews.
* For example, for a monthly subscription with subsequentCommitmentPaymentsCount of 12,
* users will be committed to another 12 monthly payments when the plan renews.
* Returns 0 if the installment plan has no subsequent commitment (reverts to normal plan).
*/
val subsequentCommitmentPaymentsCount: Int
) {

companion object {
fun fromJson(json: Map<String, Any?>): InstallmentPlanDetailsAndroid {
return InstallmentPlanDetailsAndroid(
commitmentPaymentsCount = (json["commitmentPaymentsCount"] as? Number)?.toInt() ?: 0,
subsequentCommitmentPaymentsCount = (json["subsequentCommitmentPaymentsCount"] as? Number)?.toInt() ?: 0,
)
}
}

fun toJson(): Map<String, Any?> = mapOf(
"__typename" to "InstallmentPlanDetailsAndroid",
"commitmentPaymentsCount" to commitmentPaymentsCount,
"subsequentCommitmentPaymentsCount" to subsequentCommitmentPaymentsCount,
)
}

/**
* Limited quantity information for one-time purchase offers (Android)
* Available in Google Play Billing Library 7.0+
Expand Down Expand Up @@ -1838,6 +1883,42 @@ public data class LimitedQuantityInfoAndroid(
)
}

/**
* Pending purchase update for subscription upgrades/downgrades (Android)
* When a user initiates a subscription change (upgrade/downgrade), the new purchase
* may be pending until the current billing period ends. This type contains the
* details of the pending change.
* Available in Google Play Billing Library 5.0+
*/
public data class PendingPurchaseUpdateAndroid(
/**
* Product IDs for the pending purchase update.
* These are the new products the user is switching to.
*/
val products: List<String>,
/**
* Purchase token for the pending transaction.
* Use this token to track or manage the pending purchase update.
*/
val purchaseToken: String
) {

companion object {
fun fromJson(json: Map<String, Any?>): PendingPurchaseUpdateAndroid {
return PendingPurchaseUpdateAndroid(
products = (json["products"] as? List<*>)?.mapNotNull { it as? String } ?: emptyList(),
purchaseToken = json["purchaseToken"] as? String ?: "",
)
}
}

fun toJson(): Map<String, Any?> = mapOf(
"__typename" to "PendingPurchaseUpdateAndroid",
"products" to products,
"purchaseToken" to purchaseToken,
)
}
Comment on lines +1893 to +1920

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The purchaseToken field in PendingPurchaseUpdateAndroid.fromJson is a critical security identifier. Defaulting it to an empty string ("") upon invalid input creates a medium-severity vulnerability, potentially leading to authentication bypasses or incorrect resource access. It's crucial to handle invalid or missing tokens securely, ideally by making the field nullable (String?) and assigning null to ensure downstream logic correctly handles its absence, rather than masking issues with an empty string.

public data class PendingPurchaseUpdateAndroid(
    /**
     * Product IDs for the pending purchase update.
     * These are the new products the user is switching to.
     */
    val products: List<String>,
    /**
     * Purchase token for the pending transaction.
     * Use this token to track or manage the pending purchase update.
     */
    val purchaseToken: String?
) {

    companion object {
        fun fromJson(json: Map<String, Any?>): PendingPurchaseUpdateAndroid {
            return PendingPurchaseUpdateAndroid(
                products = (json["products"] as? List<*>)?.mapNotNull { it as? String } ?: emptyList(),
                purchaseToken = json["purchaseToken"] as? String,
            )
        }
    }

    fun toJson(): Map<String, Any?> = mapOf(
        "__typename" to "PendingPurchaseUpdateAndroid",
        "products" to products,
        "purchaseToken" to purchaseToken,
    )
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This type is auto-generated from the OpenIAP GraphQL specification, where purchaseToken is defined as String! (non-nullable).

The OpenIAP spec follows Google Play Billing Library's contract where PendingPurchaseUpdate.getPurchaseToken() never returns null - it always returns a valid token when a pending update exists. The parent field pendingPurchaseUpdateAndroid is already nullable, so if there's no pending update, the entire object is null rather than having a null token.

Changes to this type should be proposed upstream at the OpenIAP specification: https://github.com/hyodotdev/openiap.dev/discussions


/**
* Pre-order details for one-time purchase products (Android)
* Available in Google Play Billing Library 8.1.0+
Expand Down Expand Up @@ -2051,6 +2132,12 @@ public data class ProductAndroidOneTimePurchaseOfferDetail(
val preorderDetailsAndroid: PreorderDetailsAndroid? = null,
val priceAmountMicros: String,
val priceCurrencyCode: String,
/**
* Purchase option ID for this offer (Android)
* Used to identify which purchase option the user selected.
* Available in Google Play Billing Library 7.0+
*/
val purchaseOptionId: String? = null,
/**
* Rental details for rental offers
*/
Expand All @@ -2074,6 +2161,7 @@ public data class ProductAndroidOneTimePurchaseOfferDetail(
preorderDetailsAndroid = (json["preorderDetailsAndroid"] as? Map<String, Any?>)?.let { PreorderDetailsAndroid.fromJson(it) },
priceAmountMicros = json["priceAmountMicros"] as? String ?: "",
priceCurrencyCode = json["priceCurrencyCode"] as? String ?: "",
purchaseOptionId = json["purchaseOptionId"] as? String,
rentalDetailsAndroid = (json["rentalDetailsAndroid"] as? Map<String, Any?>)?.let { RentalDetailsAndroid.fromJson(it) },
validTimeWindow = (json["validTimeWindow"] as? Map<String, Any?>)?.let { ValidTimeWindowAndroid.fromJson(it) },
)
Expand All @@ -2092,6 +2180,7 @@ public data class ProductAndroidOneTimePurchaseOfferDetail(
"preorderDetailsAndroid" to preorderDetailsAndroid?.toJson(),
"priceAmountMicros" to priceAmountMicros,
"priceCurrencyCode" to priceCurrencyCode,
"purchaseOptionId" to purchaseOptionId,
"rentalDetailsAndroid" to rentalDetailsAndroid?.toJson(),
"validTimeWindow" to validTimeWindow?.toJson(),
)
Expand Down Expand Up @@ -2264,6 +2353,12 @@ public data class ProductSubscriptionAndroid(
*/
public data class ProductSubscriptionAndroidOfferDetails(
val basePlanId: String,
/**
* Installment plan details for this subscription offer.
* Only set for installment subscription plans; null for non-installment plans.
* Available in Google Play Billing Library 7.0+
*/
val installmentPlanDetails: InstallmentPlanDetailsAndroid? = null,
val offerId: String? = null,
val offerTags: List<String>,
val offerToken: String,
Expand All @@ -2274,6 +2369,7 @@ public data class ProductSubscriptionAndroidOfferDetails(
fun fromJson(json: Map<String, Any?>): ProductSubscriptionAndroidOfferDetails {
return ProductSubscriptionAndroidOfferDetails(
basePlanId = json["basePlanId"] as? String ?: "",
installmentPlanDetails = (json["installmentPlanDetails"] as? Map<String, Any?>)?.let { InstallmentPlanDetailsAndroid.fromJson(it) },
offerId = json["offerId"] as? String,
offerTags = (json["offerTags"] as? List<*>)?.mapNotNull { it as? String } ?: emptyList(),
offerToken = json["offerToken"] as? String ?: "",
Expand All @@ -2285,6 +2381,7 @@ public data class ProductSubscriptionAndroidOfferDetails(
fun toJson(): Map<String, Any?> = mapOf(
"__typename" to "ProductSubscriptionAndroidOfferDetails",
"basePlanId" to basePlanId,
"installmentPlanDetails" to installmentPlanDetails?.toJson(),
"offerId" to offerId,
"offerTags" to offerTags,
"offerToken" to offerToken,
Expand Down Expand Up @@ -2410,6 +2507,13 @@ public data class PurchaseAndroid(
val obfuscatedAccountIdAndroid: String? = null,
val obfuscatedProfileIdAndroid: String? = null,
val packageNameAndroid: String? = null,
/**
* Pending purchase update for uncommitted subscription upgrade/downgrade (Android)
* Contains the new products and purchase token for the pending transaction.
* Returns null if no pending update exists.
* Available in Google Play Billing Library 5.0+
*/
val pendingPurchaseUpdateAndroid: PendingPurchaseUpdateAndroid? = null,
override val platform: IapPlatform,
override val productId: String,
override val purchaseState: PurchaseState,
Expand Down Expand Up @@ -2439,6 +2543,7 @@ public data class PurchaseAndroid(
obfuscatedAccountIdAndroid = json["obfuscatedAccountIdAndroid"] as? String,
obfuscatedProfileIdAndroid = json["obfuscatedProfileIdAndroid"] as? String,
packageNameAndroid = json["packageNameAndroid"] as? String,
pendingPurchaseUpdateAndroid = (json["pendingPurchaseUpdateAndroid"] as? Map<String, Any?>)?.let { PendingPurchaseUpdateAndroid.fromJson(it) },
platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: IapPlatform.Ios,
productId = json["productId"] as? String ?: "",
purchaseState = (json["purchaseState"] as? String)?.let { PurchaseState.fromJson(it) } ?: PurchaseState.Pending,
Expand Down Expand Up @@ -2466,6 +2571,7 @@ public data class PurchaseAndroid(
"obfuscatedAccountIdAndroid" to obfuscatedAccountIdAndroid,
"obfuscatedProfileIdAndroid" to obfuscatedProfileIdAndroid,
"packageNameAndroid" to packageNameAndroid,
"pendingPurchaseUpdateAndroid" to pendingPurchaseUpdateAndroid?.toJson(),
"platform" to platform.toJson(),
"productId" to productId,
"purchaseState" to purchaseState.toJson(),
Expand Down Expand Up @@ -2876,6 +2982,12 @@ public data class SubscriptionOffer(
* - Android: offerId from ProductSubscriptionAndroidOfferDetails
*/
val id: String,
/**
* [Android] Installment plan details for this subscription offer.
* Only set for installment subscription plans; null for non-installment plans.
* Available in Google Play Billing Library 7.0+
*/
val installmentPlanDetailsAndroid: InstallmentPlanDetailsAndroid? = null,
/**
* [iOS] Key identifier for signature validation.
* Used with server-side signature generation for promotional offers.
Expand Down Expand Up @@ -2947,6 +3059,7 @@ public data class SubscriptionOffer(
currency = json["currency"] as? String,
displayPrice = json["displayPrice"] as? String ?: "",
id = json["id"] as? String ?: "",
installmentPlanDetailsAndroid = (json["installmentPlanDetailsAndroid"] as? Map<String, Any?>)?.let { InstallmentPlanDetailsAndroid.fromJson(it) },
keyIdentifierIOS = json["keyIdentifierIOS"] as? String,
localizedPriceIOS = json["localizedPriceIOS"] as? String,
nonceIOS = json["nonceIOS"] as? String,
Expand All @@ -2971,6 +3084,7 @@ public data class SubscriptionOffer(
"currency" to currency,
"displayPrice" to displayPrice,
"id" to id,
"installmentPlanDetailsAndroid" to installmentPlanDetailsAndroid?.toJson(),
"keyIdentifierIOS" to keyIdentifierIOS,
"localizedPriceIOS" to localizedPriceIOS,
"nonceIOS" to nonceIOS,
Expand Down Expand Up @@ -4593,7 +4707,7 @@ public interface MutationResolver {
*/
suspend fun presentCodeRedemptionSheetIOS(): Boolean
/**
* Present external purchase custom link with StoreKit UI (iOS 17.4+)
* Present external purchase custom link with StoreKit UI
*/
suspend fun presentExternalPurchaseLinkIOS(url: String): ExternalPurchaseLinkResultIOS
/**
Expand Down
4 changes: 2 additions & 2 deletions openiap-versions.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"apple": "1.3.14",
"google": "1.3.27",
"gql": "1.3.16",
"google": "1.3.28",
"gql": "1.3.17",
"kmp-iap": "1.3.6"
}
Loading