Skip to main content
Version: 3.2

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?.subscriptionOfferDetailsAndroid) {
console.log(
'Available offers:',
subscription.subscriptionOfferDetailsAndroid,
);
// Each offer contains: basePlanId, offerId?, offerTags, offerToken, pricingPhases
}
};

Purchase with Offers​

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

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

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

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

Understanding Offer Details​

Each subscriptionOfferDetailsAndroid item contains:

interface ProductSubscriptionAndroidOfferDetails {
basePlanId: string; // Base plan identifier
offerId?: string | null; // Offer identifier (null for base plan)
offerTags: string[]; // Tags associated with the offer
offerToken: string; // Token required for purchase
pricingPhases: PricingPhasesAndroid; // Pricing information
}
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
const offers = subscription.subscriptionOfferDetailsAndroid ?? [];

if (offerType === 'base') {
// Find base plan (no offerId)
return offers.find((offer) => !offer.offerId);
} else {
// Find introductory offer
return offers.find((offer) => offer.offerId?.includes('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​