Skip to main content

Promotional Subscription Extensions

Automatically extend subscriptions as a promotional offer when customers make a purchase

AIAsk AIChatGPTClaude

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:

  1. Customer purchases a subscription through your eligible offering
  2. RevenueCat sends an INITIAL_PURCHASE webhook to your server
  3. Your server identifies the eligible purchase (e.g., by checking the offering ID)
  4. Your server calls the RevenueCat API to extend (Apple) or defer (Google) the subscription
  5. Customer's subscription is extended by the promotional period

Prerequisites

Before implementing promotional subscription extensions, ensure you have:

⚠️Secret API Key Required

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.

⚠️Use at Your Own Discretion

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.

ConsiderationsDetails
Extension methodNumber of days to extend (1-90)
Maximum extension90 days per request
Extensions per yearMaximum of 2 extensions per customer per year
Customer notificationApple immediately emails the customer about the extension
Sandbox behaviorExtension days are treated as minutes in sandbox

Extension Reason Codes:

CodeReason
0Undeclared
1Customer Satisfaction
2Other
3Service Issue or Outage

Google Play Store

Google Play Store subscriptions can be extended using the Defer a Subscription endpoint.

ConsiderationsDetails
Extension methodSpecify extend_by_days or set a new expiry_time_ms
Maximum extensionUp to 365 days (one year) per request
Extensions per yearNo limit
Customer notificationGoogle does not automatically notify the customer
Product identifierUse the Subscription ID from RevenueCat's Product catalog
📘Google Play Product Identifiers

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

PlatformExtension 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 POST requests with JSON body
  • Return a 200 status code quickly (within 60 seconds)
  • (Optionally) use an authorization header for security
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:

{
"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 time
  • offer_code: Any offer/promo code used during purchase
  • product_id: The specific product purchased
  • store: 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:

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 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:

ParameterRequiredTypeDescription
extend_by_daysintegerNumber of days to extend (1-90)
extend_reason_codeintegerReason for extension (0-3)

Google Play Store 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):

ParameterRequiredTypeDescription
extend_by_days✅ Yes, if not using expiry_time_msintegerNumber of days to extend (1-365).
expiry_time_ms✅ Yes, if not using extend_by_daysintegerNew expiration timestamp in milliseconds since epoch.

Step 5: Complete Handler Implementation

Here's a complete implementation that handles both platforms:

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:

  1. Check the API response: A successful response returns the updated Customer Info
  2. Look for the SUBSCRIPTION_EXTENDED webhook: RevenueCat will send this event after a successful extension
  3. 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;
}