Promotional Subscription Extensions
Automatically extend subscriptions as a promotional offer when customers make a purchase
Promotional subscription extensions allow you to reward customers with additional subscription time when they purchase through a specific offering or promotional campaign.
For example, you might offer "Buy a monthly subscription during our holiday sale and get an extra month free!" The customer purchases the subscription at the normal price, and your backend automatically extends their subscription by the promotional period.
How It Works
The flow for implementing promotional subscription extensions is:
- Customer purchases a subscription through your eligible offering
- RevenueCat sends an
INITIAL_PURCHASEwebhook to your server - Your server identifies the eligible purchase (e.g., by checking the offering ID)
- Your server calls the RevenueCat API to extend (Apple) or defer (Google) the subscription
- Customer's subscription is extended by the promotional period
Prerequisites
Before implementing promotional subscription extensions, ensure you have:
- A RevenueCat project with your apps configured
- Webhooks set up to receive events from RevenueCat
- A Secret API key (v1) from your RevenueCat project settings (required for server-side API calls)
- For Apple: App Store Server Notifications configured
- For Google: Real-time Developer Notifications configured
The subscription extension and deferral APIs require a Secret API key. Never expose this key in client-side code. All extension requests must be made from your secure backend server.
Platform Considerations
Apple App Store
Apple App Store subscriptions can be extended using the Extend a Subscription endpoint.
It's unclear whether using subscription extensions for promotional offers aligns with Apple's intended use of the API. Apple's documentation primarily references service outages and customer satisfaction issues as example use cases. However, there are real-world examples of apps successfully using extensions in this promotional manner.
Consider your own risk tolerance and consult Apple's latest guidelines before implementing.
| Considerations | Details |
|---|---|
| Extension method | Number of days to extend (1-90) |
| Maximum extension | 90 days per request |
| Extensions per year | Maximum of 2 extensions per customer per year |
| Customer notification | Apple immediately emails the customer about the extension |
| Sandbox behavior | Extension days are treated as minutes in sandbox |
Extension Reason Codes:
| Code | Reason |
|---|---|
0 | Undeclared |
1 | Customer Satisfaction |
2 | Other |
3 | Service Issue or Outage |
Google Play Store
Google Play Store subscriptions can be extended using the Defer a Subscription endpoint.
| Considerations | Details |
|---|---|
| Extension method | Specify extend_by_days or set a new expiry_time_ms |
| Maximum extension | Up to 365 days (one year) per request |
| Extensions per year | No limit |
| Customer notification | Google does not automatically notify the customer |
| Product identifier | Use the Subscription ID from RevenueCat's Product catalog |
For Google Play products set up in RevenueCat after February 2023, the product_id in webhooks has the format <subscription_id>:<base_plan_id>. When calling the defer endpoint, use only the subscription ID portion (before the colon).
Other Platforms
| Platform | Extension Support |
|---|---|
| Amazon Appstore | ❌ Not supported |
| Roku | ❌ Not supported |
Implementation
Step 1: Set Up Your Webhook Endpoint
Create an endpoint to receive webhooks from RevenueCat. Your endpoint must:
- Accept
POSTrequests with JSON body - Return a
200status code quickly (within 60 seconds) - (Optionally) use an authorization header for security
- Webhook Endpoint
import express, { Request, Response } from "express";
const app = express();
app.use(express.json());
const WEBHOOK_AUTH_HEADER = process.env.WEBHOOK_AUTH_HEADER!;
app.post("/webhooks/revenuecat", async (req: Request, res: Response) => {
// Verify authorization header
const authHeader = req.headers.authorization;
if (authHeader !== WEBHOOK_AUTH_HEADER) {
return res.status(401).json({ error: "Unauthorized" });
}
// Respond immediately to avoid timeout
res.status(200).json({ received: true });
// Process asynchronously
processWebhook(req.body);
});
Step 2: Handle the INITIAL_PURCHASE Event
When a customer makes a new subscription purchase, RevenueCat sends an INITIAL_PURCHASE webhook. Here's an example payload:
- INITIAL_PURCHASE Webhook
{
"event": {
"type": "INITIAL_PURCHASE",
"id": "12345678-1234-1234-1234-123456789012",
"app_id": "app1234567890",
"event_timestamp_ms": 1702819200000,
"product_id": "com.yourapp.subscription.monthly",
"period_type": "NORMAL",
"purchased_at_ms": 1702819200000,
"expiration_at_ms": 1705497600000,
"environment": "PRODUCTION",
"entitlement_ids": ["premium"],
"presented_offering_id": "holiday_promo",
"transaction_id": "2000000456789012",
"original_transaction_id": "2000000456789012",
"is_family_share": false,
"country_code": "US",
"app_user_id": "user_abc123",
"aliases": ["$RCAnonymousID:8069238d6049ce87cc529853916d624c"],
"original_app_user_id": "user_abc123",
"currency": "USD",
"price": 9.99,
"price_in_purchased_currency": 9.99,
"subscriber_attributes": {
"$email": {
"updated_at_ms": 1702819200000,
"value": "customer@example.com"
}
},
"store": "APP_STORE",
"tax_percentage": 0.0,
"commission_percentage": 0.3,
"offer_code": "HOLIDAY2024"
},
"api_version": "1.0"
}
Key fields to check for promotional eligibility:
presented_offering_id: The offering shown to the customer at purchase timeoffer_code: Any offer/promo code used during purchaseproduct_id: The specific product purchasedstore: The platform (APP_STORE, PLAY_STORE, etc.)
Step 3: Implement Platform-Specific Extension Logic
The API endpoints differ between Apple and Google. Here's how to handle both platforms:
- Platform Router
async function applyPromotionalExtension(
event: WebhookEvent["event"]
): Promise<void> {
const { store, app_user_id, transaction_id, product_id } = event;
switch (store) {
case "APP_STORE":
case "MAC_APP_STORE":
// Apple subscriptions use the extend endpoint with transaction ID
await extendAppleSubscription(app_user_id, transaction_id);
break;
case "PLAY_STORE":
// Google subscriptions use the defer endpoint with subscription ID
// Extract subscription ID (before the colon) from product_id
const subscriptionId = product_id.split(":")[0];
await deferGoogleSubscription(app_user_id, subscriptionId);
break;
case "STRIPE":
case "RC_BILLING":
// Stripe and Web Billing don't support direct extensions via RevenueCat
// You would need to use Stripe's API directly or grant an entitlement
console.log(`${store} requires direct integration for extensions`);
break;
case "AMAZON":
case "ROKU":
// These stores don't support subscription extensions
console.log(`${store} does not support subscription extensions`);
break;
default:
console.log(`Unknown store: ${store}`);
}
}
Step 4: Call the Extension APIs
Apple App Store Extension
- Apple Extension
// Apple App Store: Extend a subscription renewal date
const response = await fetch(
`https://api.revenuecat.com/v1/subscribers/${encodeURIComponent(
appUserId
)}/subscriptions/${transactionId}/extend`,
{
method: "POST",
headers: {
Authorization: `Bearer ${REVENUECAT_V1_SECRET_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
extend_by_days: 30, // 1-90 days
extend_reason_code: 1, // 1 = Customer Satisfaction
}),
}
);
Parameters:
| Parameter | Required | Type | Description |
|---|---|---|---|
extend_by_days | ✅ | integer | Number of days to extend (1-90) |
extend_reason_code | ✅ | integer | Reason for extension (0-3) |
Google Play Store Deferral
- Google Deferral
// Google Play Store: Defer a subscription renewal date
// Extract subscription ID (before the colon) from product_id
// e.g., "subscription_id:base_plan_id" -> "subscription_id"
const subscriptionId = productId.split(":")[0];
const response = await fetch(
`https://api.revenuecat.com/v1/subscribers/${encodeURIComponent(
appUserId
)}/subscriptions/${encodeURIComponent(subscriptionId)}/defer`,
{
method: "POST",
headers: {
Authorization: `Bearer ${REVENUECAT_V1_SECRET_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
extend_by_days: 30, // 1-365 days
}),
}
);
Parameters (one required):
| Parameter | Required | Type | Description |
|---|---|---|---|
extend_by_days | ✅ Yes, if not using expiry_time_ms | integer | Number of days to extend (1-365). |
expiry_time_ms | ✅ Yes, if not using extend_by_days | integer | New expiration timestamp in milliseconds since epoch. |
Step 5: Complete Handler Implementation
Here's a complete implementation that handles both platforms:
- Complete Handler
import express, { Request, Response } from "express";
const app = express();
app.use(express.json());
// Your RevenueCat Secret API Key (v1)
const REVENUECAT_API_KEY = process.env.REVENUECAT_SECRET_API_KEY!;
const WEBHOOK_AUTH_HEADER = process.env.WEBHOOK_AUTH_HEADER!;
// Configure your promotional extension settings
const PROMO_CONFIG = {
// Offering IDs that qualify for the promotional extension
eligibleOfferingIds: ["holiday_promo", "partner_deal", "retention_offer"],
// Number of days to extend the subscription
extensionDays: 30,
// Apple extension reason code (1 = Customer Satisfaction)
appleReasonCode: 1,
};
interface WebhookEvent {
event: {
type: string;
app_user_id: string;
original_transaction_id: string;
transaction_id: string;
store: string;
product_id: string;
presented_offering_id: string | null;
environment: string;
};
api_version: string;
}
// Webhook endpoint to receive RevenueCat events
app.post(
"/webhooks/revenuecat",
async (req: Request, res: Response): Promise<void> => {
// Verify the authorization header
const authHeader = req.headers.authorization;
if (authHeader !== WEBHOOK_AUTH_HEADER) {
res.status(401).json({ error: "Unauthorized" });
return;
}
// Respond immediately to avoid timeout
res.status(200).json({ received: true });
// Process the webhook asynchronously
const webhookData: WebhookEvent = req.body;
await processWebhook(webhookData);
}
);
async function processWebhook(webhookData: WebhookEvent): Promise<void> {
const { event } = webhookData;
// Only process initial purchases
if (event.type !== "INITIAL_PURCHASE") {
console.log(`Skipping event type: ${event.type}`);
return;
}
// Check if this purchase qualifies for promotional extension
if (!isEligibleForPromoExtension(event)) {
console.log(`Purchase not eligible for promotional extension`);
return;
}
try {
// Apply the extension based on the store platform
await applyPromotionalExtension(event);
console.log(
`Successfully applied promotional extension for user: ${event.app_user_id}`
);
} catch (error) {
console.error(`Failed to apply promotional extension:`, error);
// Implement your error handling/retry logic here
}
}
function isEligibleForPromoExtension(event: WebhookEvent["event"]): boolean {
// Check if the offering qualifies for the promotion
if (!event.presented_offering_id) {
return false;
}
return PROMO_CONFIG.eligibleOfferingIds.includes(event.presented_offering_id);
}
async function applyPromotionalExtension(
event: WebhookEvent["event"]
): Promise<void> {
const { store, app_user_id, transaction_id, product_id } = event;
switch (store) {
case "APP_STORE":
case "MAC_APP_STORE":
await extendAppleSubscription(app_user_id, transaction_id);
break;
case "PLAY_STORE":
// Extract subscription ID (before the colon) from product_id
// e.g., "subscription_id:base_plan_id" -> "subscription_id"
const subscriptionId = product_id.split(":")[0];
await deferGoogleSubscription(app_user_id, subscriptionId);
break;
default:
console.log(`Store ${store} does not support subscription extensions`);
}
}
async function extendAppleSubscription(
appUserId: string,
transactionId: string
): Promise<void> {
const url = `https://api.revenuecat.com/v1/subscribers/${encodeURIComponent(
appUserId
)}/subscriptions/${transactionId}/extend`;
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${REVENUECAT_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
extend_by_days: PROMO_CONFIG.extensionDays,
extend_reason_code: PROMO_CONFIG.appleReasonCode,
}),
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(
`Apple extension failed: ${response.status} - ${errorBody}`
);
}
console.log(`Extended Apple subscription by ${PROMO_CONFIG.extensionDays} days`);
}
async function deferGoogleSubscription(
appUserId: string,
subscriptionId: string
): Promise<void> {
const url = `https://api.revenuecat.com/v1/subscribers/${encodeURIComponent(
appUserId
)}/subscriptions/${encodeURIComponent(subscriptionId)}/defer`;
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${REVENUECAT_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
extend_by_days: PROMO_CONFIG.extensionDays,
}),
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(
`Google deferral failed: ${response.status} - ${errorBody}`
);
}
console.log(`Deferred Google subscription by ${PROMO_CONFIG.extensionDays} days`);
}
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Webhook server listening on port ${PORT}`);
});
Verifying Extensions
After applying an extension, you can verify it was successful by:
- Check the API response: A successful response returns the updated Customer Info
- Look for the
SUBSCRIPTION_EXTENDEDwebhook: RevenueCat will send this event after a successful extension - Check the Customer Profile: In the RevenueCat dashboard, view the customer's profile to see the updated expiration date
Best Practices
Avoiding Duplicate Extensions
Before applying an extension, verify one hasn't already been applied to avoid extending a subscription multiple times. There are several approaches:
1. Track extensions in your database
Store a record of extensions you've applied, keyed by the original_transaction_id. Before extending, check if an extension already exists for that transaction.
2. Listen for SUBSCRIPTION_EXTENDED webhooks
RevenueCat sends a SUBSCRIPTION_EXTENDED webhook after a successful extension. Track these events to know which subscriptions have already been extended.
3. Compare purchase date to expiration date
For a standard subscription, the gap between purchased_at_ms and expiration_at_ms matches the subscription period (e.g., ~30 days for monthly). If the gap is larger than expected, an extension may have already been applied.
4. Fetch current customer info before extending
Call the GET /subscribers endpoint to check the current expires_date before applying an extension. Compare it against what you expect based on the original purchase.
Respond Quickly
RevenueCat will timeout webhook requests after 60 seconds. Always respond with a 200 status immediately, then process the extension asynchronously.
Monitor Apple's Extension Limits
Apple limits extensions to 2 per customer per year. Consider tracking extensions and repurposing granted entitlements if the limit is reached.
Handle Sandbox vs Production
The environment field in the webhook indicates whether it's a sandbox or production purchase. You may want to:
- Skip extensions for sandbox purchases
- Use different extension durations for testing
- Log sandbox events separately
if (event.environment === "SANDBOX") {
console.log("Sandbox purchase - skipping promotional extension");
return;
}