Subscription Offers
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
subscriptionOfferswith one or more offer tokens fromfetchProducts(). - 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 'react-native-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 with proper filtering
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: {
ios: {sku: subscriptionId},
android: {
skus: [subscriptionId],
subscriptionOffers, // Now guaranteed to have valid offers
},
},
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
}
iOS Subscription Offers
iOS handles subscription offers differently - the base plan is used by default, and promotional offers are optional.
Introductory Offers (Automatic)
iOS automatically applies introductory prices (free trials, pay-as-you-go, or pay-up-front) to eligible users when they purchase a subscription. No additional code is needed - the App Store handles this automatically based on your App Store Connect configuration.
Detecting Introductory Offers:
const subscription = subscriptions.find((s) => s.id === 'premium_monthly');
// Check if this subscription has an introductory offer configured
const introOffer = subscription?.subscriptionInfoIOS?.introductoryOffer;
if (introOffer) {
console.log('Introductory offer available:');
console.log('Type:', introOffer.type); // 'freeTrial' | 'introductory' | 'subscription'
console.log('Price:', introOffer.price); // e.g., 0 for free trial
console.log('Period:', introOffer.period); // Duration info
console.log('Payment Mode:', introOffer.paymentMode); // 'payAsYouGo' | 'payUpFront' | 'freeTrial'
// Display to user based on payment mode
switch (introOffer.paymentMode.toLowerCase()) {
case 'freetrial':
console.log(
`Free trial: ${introOffer.period.numberOfUnits} ${introOffer.period.unit.toLowerCase()}`,
);
break;
case 'payasyougo':
console.log(
`Introductory price: ${introOffer.price} for ${introOffer.period.numberOfUnits} ${introOffer.period.unit.toLowerCase()}`,
);
break;
case 'payupfront':
console.log(
`One-time payment: ${introOffer.price} for ${introOffer.period.numberOfUnits} ${introOffer.period.unit.toLowerCase()}`,
);
break;
}
}
Note: Introductory offers are automatically applied when eligible. You cannot manually select or apply them - they're determined by the user's purchase history and your App Store Connect configuration.
Base Plan (Default)
For standard subscription purchases, no special offer specification is needed:
await requestPurchase({
request: {
ios: {sku: 'premium_monthly'},
android: {
skus: [
'premium_monthly',
] /* include subscriptionOffers only if available */,
},
},
type: 'subs',
});
Promotional Offers (Optional)
iOS supports promotional offers through the withOffer parameter. Unlike introductory offers, promotional offers require explicit activation and signature generation.
Getting Available Promotional Offers:
const subscription = subscriptions.find((s) => s.id === 'premium_monthly');
// Check for available promotional offers
const promotionalOffers = subscription?.discountsIOS ?? [];
if (promotionalOffers.length > 0) {
promotionalOffers.forEach((discount) => {
console.log('Promotional offer:', discount.identifier);
console.log('Price:', discount.price);
console.log('Type:', discount.type); // 'promotional' or 'introductory'
});
}
Applying Promotional Offers:
interface DiscountOfferInputIOS {
offerIdentifier: string; // From App Store Connect
keyIdentifier: string; // From App Store Connect
nonce: string; // UUID v4 (lowercase) - generated by your backend
signature: string; // Base64-encoded signature - generated by your backend
timestamp: number; // Unix timestamp in milliseconds - generated by your backend
}
const purchaseWithPromotionalOffer = async (
subscriptionId: string,
offerIdentifier: string,
) => {
// Step 1: Get the signature from your backend
const signatureData = await fetch('https://your-backend.com/generate-signature', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
productId: subscriptionId,
offerIdentifier: offerIdentifier,
applicationUsername: 'user-123', // Optional user identifier
}),
}).then((res) => res.json());
// Step 2: Purchase with the promotional offer
await requestPurchase({
request: {
ios: {
sku: subscriptionId,
withOffer: {
offerIdentifier: offerIdentifier, // From App Store Connect
keyIdentifier: signatureData.keyIdentifier, // From your backend
nonce: signatureData.nonce, // From your backend
signature: signatureData.signature, // From your backend (base64)
timestamp: signatureData.timestamp, // From your backend
},
},
android: {skus: [subscriptionId], subscriptionOffers: [...]},
},
type: 'subs',
});
};
Server-Side Signature Generation
Promotional offer signatures must be generated on your server and must be base64-encoded. Here's a complete example:
// Node.js server example
const crypto = require('crypto');
const {v4: uuidv4} = require('uuid');
function generatePromotionalOfferSignature(
bundleId, // Your app's bundle identifier (e.g., "com.example.app")
productId, // Product identifier (e.g., "premium_monthly")
offerId, // Offer identifier from App Store Connect
applicationUsername, // User identifier (appAccountToken)
privateKey, // PKCS#8 PEM-formatted private key from App Store Connect
keyId, // Key ID from App Store Connect
) {
// Generate nonce and timestamp
const nonce = uuidv4().toLowerCase(); // MUST be lowercase UUID
const timestamp = Date.now(); // Milliseconds since Unix epoch
// ⭐ CRITICAL: Data must be joined in this exact order
const dataToSign = [
bundleId, // 1. App Bundle ID
keyId, // 2. Key Identifier
productId, // 3. Product Identifier
offerId, // 4. Offer Identifier
applicationUsername, // 5. Application Username (appAccountToken)
nonce, // 6. Nonce (lowercase UUID)
timestamp.toString(), // 7. Timestamp (milliseconds)
].join('\u2063'); // Join with invisible separator (U+2063)
// Sign the data with SHA-256
const sign = crypto.createSign('sha256');
sign.update(dataToSign);
const signatureBuffer = sign.sign({
key: privateKey,
format: 'pem',
type: 'pkcs8',
});
// ⭐ CRITICAL: Signature MUST be base64-encoded
const base64Signature = signatureBuffer.toString('base64');
return {
identifier: offerId,
keyIdentifier: keyId,
nonce: nonce, // Lowercase UUID
signature: base64Signature, // Base64-encoded signature
timestamp: timestamp, // Milliseconds
};
}
// Usage example
app.post('/generate-offer-signature', (req, res) => {
const {productId, offerId, applicationUsername} = req.body;
const signature = generatePromotionalOfferSignature(
process.env.APP_BUNDLE_ID, // From environment
productId,
offerId,
applicationUsername,
process.env.APPLE_PRIVATE_KEY, // PKCS#8 PEM private key
process.env.APPLE_KEY_ID, // From App Store Connect
);
res.json(signature);
});
Important Notes:
- Data Order: The 7 fields MUST be joined in the exact order shown above (as per Apple's documentation)
- Nonce: Must be a lowercase UUID v4 string
- Timestamp: Must be in milliseconds (not seconds)
- Signature: Must be base64-encoded (not hex or raw)
- Private Key: Must be PKCS#8 PEM format from App Store Connect
- Separator: Use Unicode character U+2063 (invisible separator)
Common Errors:
- "The data couldn't be read because it isn't in the correct format" → Signature is not base64-encoded
- "Invalid signature" → Incorrect data order or wrong separator
- "Signature verification failed" → Wrong private key or key ID
Common Patterns
Selecting Specific Offers
const selectOffer = (
subscription: ProductSubscription,
offerType: 'base' | 'introductory',
) => {
if (Platform.OS === 'ios') {
// iOS: Return introductory offer if requested and available
if (offerType === 'introductory') {
return subscription.subscriptionInfoIOS?.introductoryOffer ?? null;
}
// Base plan doesn't need explicit selection
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: {
ios: {sku: subscriptionId},
android: {
skus: [subscriptionId],
// Required for Android: include only when you found a valid offer
...(subscriptionOffers.length > 0 && {subscriptionOffers}),
},
},
type: 'subs',
});
} else {
// iOS: Could add promotional offer logic here
await requestPurchase({
request: {
ios: {sku: subscriptionId},
android: {
skus: [
subscriptionId,
] /* include subscriptionOffers only if available */,
},
},
type: 'subs',
});
}
};
Error Handling
Android Errors
import {useIAP, ErrorCode} from 'react-native-iap';
const {requestPurchase} = useIAP({
onPurchaseError: (error) => {
// Check for specific error code
if (error.code === ErrorCode.SkuOfferMismatch) {
console.error('SKU and offer mismatch');
// Ensure subscriptionOffers is included and valid
}
},
});
iOS Errors
import {useIAP, ErrorCode} from 'react-native-iap';
const {requestPurchase} = useIAP({
onPurchaseError: (error) => {
// Check for generic errors that may indicate offer issues
if (error.code === ErrorCode.Unknown) {
console.error('Invalid promotional offer for iOS');
// Check offerIdentifier, signature, etc.
}
},
});
Best Practices
-
Always fetch products first: Subscription offers are only available after
fetchProducts(). -
Handle platform differences: Android requires offers, iOS makes them optional.
-
Validate offers: Check that offers exist before attempting purchase.
-
User selection: Allow users to choose between different pricing plans when multiple offers are available.
-
Error recovery: Provide fallback to base plan if selected offer fails.
See Also
- useIAP Hook - Main API documentation
- Subscription Flow Example - Complete implementation
- Error Codes - Purchase error handling
