Skip to main content
Version: 3.4 (Current)

Subscription Offers

IAPKit - In-App Purchase Solution

This guide explains how to handle subscription offers (pricing plans) when purchasing subscriptions on iOS and Android platforms.

For a complete implementation example, see the Subscription Flow Example.

Overview​

Subscription offers represent different pricing plans for the same subscription product:

  • Base Plan: The standard pricing for a subscription
  • Introductory Offers: Special pricing for new subscribers (free trial, discounted period)
  • Promotional Offers: Limited-time discounts configured in the app stores

Platform Differences​

At a glance:

  • Android: subscription offers are required when purchasing subscriptions. You must pass subscriptionOffers with one or more offer tokens from fetchProducts().
  • iOS: base plan is used by default. Promotional discounts are optional via withOffer.

Tip: Always fetch products first; offers only exist after fetchProducts({ type: 'subs' }).

Android Subscription Offers​

Android requires explicit specification of subscription offers when purchasing. Each offer is identified by an offerToken obtained from fetchProducts().

Required for Android Subscriptions​

Unlike iOS, Android subscriptions must include subscriptionOffers in the purchase request. Without it, the purchase will fail with:

The number of skus (1) must match: the number of offerTokens (0)

Getting Offer Tokens​

import {useIAP} from 'expo-iap';

const SubscriptionComponent = () => {
const {connected, subscriptions, fetchProducts, requestPurchase} = useIAP();

// 1) Fetch subscription products
useEffect(() => {
if (connected) {
fetchProducts({skus: ['premium_monthly'], type: 'subs'});
}
}, [connected]);

// 2) Access offer details from fetched subscriptions
const subscription = subscriptions.find((s) => s.id === 'premium_monthly');

if (subscription?.subscriptionOffers) {
console.log('Available offers:', subscription.subscriptionOffers);
// Each offer contains: id, displayPrice, paymentMode, period, offerTokenAndroid, pricingPhasesAndroid
}
};

Purchase with Offers​

const purchaseSubscription = async (subscriptionId: string) => {
const subscription = subscriptions.find((s) => s.id === subscriptionId);
if (!subscription) return;

// Build subscriptionOffers from fetched data using cross-platform subscriptionOffers
const offers = (subscription.subscriptionOffers ?? [])
.map((offer) =>
offer?.offerTokenAndroid
? {
sku: subscriptionId,
offerToken: offer.offerTokenAndroid,
}
: null,
)
.filter((offer): offer is {sku: string; offerToken: string} =>
Boolean(offer?.offerToken),
);

// Only proceed if offers are available
if (offers.length === 0) {
console.error('No subscription offers available');
return;
}

await requestPurchase({
request: {
apple: {sku: subscriptionId},
google: {
skus: [subscriptionId],
subscriptionOffers: offers.length > 0 ? offers : undefined,
},
},
type: 'subs',
});
};

Understanding Offer Details​

Each subscriptionOffers item contains (cross-platform SubscriptionOffer type):

interface SubscriptionOffer {
id: string; // Unique identifier for the offer
displayPrice: string; // Formatted price string (e.g., "$9.99/month")
price: number; // Numeric price value
currency?: string; // Currency code (ISO 4217)
type: DiscountOfferType; // 'introductory' | 'promotional' | 'one-time'
paymentMode?: PaymentMode; // 'free-trial' | 'pay-as-you-go' | 'pay-up-front' | 'unknown'
period?: SubscriptionPeriod; // Subscription period (unit + value)
periodCount?: number; // Number of periods the offer applies

// Android-specific fields
basePlanIdAndroid?: string; // Base plan identifier
offerTokenAndroid?: string; // Token required for purchase
offerTagsAndroid?: string[]; // Tags associated with the offer
pricingPhasesAndroid?: PricingPhasesAndroid; // Detailed pricing phases

// iOS-specific fields
keyIdentifierIOS?: string; // Key identifier for signature validation
nonceIOS?: string; // Cryptographic nonce (UUID)
signatureIOS?: string; // Server-generated signature
timestampIOS?: number; // Timestamp when signature was generated
}
Android basePlanId Limitation

The basePlanId is available when fetching products, but not when retrieving purchases via getAvailablePurchases(). This is a limitation of Google Play Billing Library - the purchase token alone doesn't reveal which base plan was purchased.

See GitHub Issue #3096 for more details. See the basePlanId Limitation section for details and workarounds.

iOS Subscription Offers​

iOS handles subscription offers differently - the base plan is used by default, and promotional offers are optional.

Base Plan (Default)​

For standard subscription purchases, no special offer specification is needed:

await requestPurchase({
request: {
apple: {sku: 'premium_monthly'},
google: {
skus: [
'premium_monthly',
] /* include subscriptionOffers only if available */,
},
},
type: 'subs',
});

Introductory Offers​

iOS automatically applies introductory prices (free trials, intro pricing) configured in App Store Connect. No additional code is needed - users will see the introductory offer when eligible.

To check if a subscription has an introductory offer:

const subscription = subscriptions.find((s) => s.id === 'premium_monthly');

if (subscription?.subscriptionInfoIOS?.introductoryOffer) {
const offer = subscription.subscriptionInfoIOS.introductoryOffer;

switch (offer.paymentMode) {
case 'free-trial':
console.log(
`${offer.periodCount} ${offer.period.unit.toLowerCase()}(s) free trial`,
);
break;
case 'pay-as-you-go':
console.log(
`${offer.displayPrice} for ${
offer.periodCount
} ${offer.period.unit.toLowerCase()}(s)`,
);
break;
case 'pay-up-front':
console.log(
`${offer.displayPrice} for first ${
offer.periodCount
} ${offer.period.unit.toLowerCase()}(s)`,
);
break;
}
}

Promotional Offers (Optional)​

iOS supports promotional offers through the withOffer parameter. These are server-to-server offers that require signature generation from your backend.

Getting Available Promotional Offers​
const subscription = subscriptions.find((s) => s.id === 'premium_monthly');

if (subscription?.discountsIOS) {
console.log('Available promotional offers:');
subscription.discountsIOS.forEach((discount) => {
console.log('- Identifier:', discount.identifier);
console.log(' Price:', discount.localizedPrice);
console.log(' Payment mode:', discount.paymentMode);
console.log(' Subscription period:', discount.subscriptionPeriod);
console.log(' Number of periods:', discount.numberOfPeriods);
});
}
Applying Promotional Offers​

To apply a promotional offer, you need to generate a signature on your backend server. See Apple's documentation for signature generation.

interface DiscountOfferInputIOS {
identifier: string; // Offer identifier from discountsIOS
keyIdentifier: string; // Your App Store Connect key ID
nonce: string; // UUID generated on device
signature: string; // Generated by your backend
timestamp: number; // Unix timestamp in milliseconds
}

const purchaseWithPromotionalOffer = async (
subscriptionId: string,
offerId: string,
) => {
// 1. Generate signature on your backend
const nonce = generateUUID(); // Use a UUID library
const timestamp = Date.now();

const signature = await fetch('https://your-backend.com/generate-signature', {
method: 'POST',
body: JSON.stringify({
productId: subscriptionId,
offerId: offerId,
applicationUsername: 'user-123', // Optional
nonce: nonce,
timestamp: timestamp,
}),
}).then((res) => res.json());

// 2. Purchase with the promotional offer
await requestPurchase({
request: {
apple: {
sku: subscriptionId,
withOffer: {
identifier: offerId,
keyIdentifier: signature.keyIdentifier,
nonce: nonce,
signature: signature.signature,
timestamp: timestamp,
},
},
google: {skus: [subscriptionId], subscriptionOffers: [...]},
},
type: 'subs',
});
};

Common Patterns​

Selecting Specific Offers​

const selectOffer = (
subscription: ProductSubscription,
offerType: 'base' | 'introductory',
) => {
if (Platform.OS === 'ios') {
// iOS: Check for introductory offer
if (offerType === 'introductory') {
return subscription.subscriptionInfoIOS?.introductoryOffer ?? null;
}
// Base plan is default, no selection needed
return null;
}

// Android: Select offer based on type using cross-platform subscriptionOffers
const offers = subscription.subscriptionOffers ?? [];

if (offerType === 'base') {
// Find base plan (offer without introductory/promotional type)
return offers.find((offer) => !offer.type || offer.type === 'one-time');
} else {
// Find introductory offer
return offers.find((offer) => offer.type === 'introductory');
}
};

const purchaseWithSelectedOffer = async (
subscriptionId: string,
offerType: 'base' | 'introductory' = 'base',
) => {
const subscription = subscriptions.find((s) => s.id === subscriptionId);
if (!subscription) return;

const selectedOffer = selectOffer(subscription, offerType);

if (Platform.OS === 'android') {
const subscriptionOffers = selectedOffer
? [
{
sku: subscriptionId,
offerToken: selectedOffer.offerToken,
},
]
: [];

if (subscriptionOffers.length === 0) {
console.error('No suitable offer found');
return;
}

await requestPurchase({
request: {
apple: {sku: subscriptionId},
google: {
skus: [subscriptionId],
subscriptionOffers:
subscriptionOffers.length > 0 ? subscriptionOffers : undefined,
},
},
type: 'subs',
});
} else {
// iOS: Introductory offers are automatically applied
// selectedOffer contains intro price info for display purposes
if (offerType === 'introductory' && selectedOffer) {
console.log('Offer:', selectedOffer.displayPrice);
console.log('Payment mode:', selectedOffer.paymentMode);
}

await requestPurchase({
request: {
apple: {sku: subscriptionId},
google: {
skus: [
subscriptionId,
] /* include subscriptionOffers only if available */,
},
},
type: 'subs',
});
}
};

Error Handling​

Android Errors​

import {useIAP, ErrorCode} from 'expo-iap';

const {requestPurchase} = useIAP({
onPurchaseError: (error) => {
if (error.code === ErrorCode.PurchaseError) {
console.error('Purchase failed - check subscription offers');
// Ensure subscriptionOffers is included and valid
}
},
});

iOS Errors​

import {useIAP, ErrorCode} from 'expo-iap';

const {requestPurchase} = useIAP({
onPurchaseError: (error) => {
if (error.code === ErrorCode.Unknown) {
console.error('Invalid promotional offer for iOS');
// Check offerIdentifier, signature, etc.
}
},
});

Best Practices​

  1. Always fetch products first: Subscription offers are only available after fetchProducts().

  2. Handle platform differences: Android requires offers, iOS makes them optional.

  3. Validate offers: Check that offers exist before attempting purchase.

  4. User selection: Allow users to choose between different pricing plans when multiple offers are available.

  5. Error recovery: Provide fallback to base plan if selected offer fails.

See Also​