---
id: "guides/promotional-subscription-extensions"
title: "Promotional Subscription Extensions"
description: "Promotional subscription extensions allow you to reward customers with additional subscription time when they purchase through a specific offering or promotional campaign."
permalink: "/docs/guides/promotional-subscription-extensions"
slug: "promotional-subscription-extensions"
version: "current"
original_source: "docs/guides/promotional-subscription-extensions.mdx"
---

> **AI agents:** This is the Markdown version of a RevenueCat documentation page. For the complete documentation index, see [llms.txt](https://www.revenuecat.com/docs/llms.txt).

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:

- A RevenueCat project with your apps configured
- [Webhooks](https://www.revenuecat.com/docs/integrations/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](https://www.revenuecat.com/docs/platform-resources/server-notifications/apple-server-notifications) configured
- For Google: [Real-time Developer Notifications](https://www.revenuecat.com/docs/platform-resources/server-notifications/google-server-notifications) configured

:::warning\[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](https://www.revenuecat.com/api-v1#tag/transactions/operation/extend-an-apple-subscription) endpoint.

:::warning\[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.
:::

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

:::info\[Subscriptions in a Free Offer Period Cannot Be Extended]
Apple's [Extend a Subscription Renewal Date](https://developer.apple.com/documentation/appstoreserverapi/extend-a-subscription-renewal-date) API does not support subscriptions that are currently in a free offer period (e.g., a free trial). Attempting to extend one will return a [`SubscriptionExtensionIneligibleError`](https://developer.apple.com/documentation/appstoreserverapi/subscriptionextensionineligibleerror). Other ineligible states include subscriptions in billing retry, expired subscriptions, and subscriptions in a grace period with a past expiration date.

If you want to reward users with additional free time, consider setting up [offer codes or promotional offers](https://www.revenuecat.com/docs/subscription-guidance/subscription-offers/ios-subscription-offers) instead. For immediate access outside the store subscription, you can also [grant a promotional entitlement](https://www.revenuecat.com/docs/dashboard-and-metrics/customer-profile#granting-a-promotional-entitlement) from the dashboard or via the [REST API](https://www.revenuecat.com/api-v1#tag/entitlements/operation/grant-a-promotional-entitlement).
:::

**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](https://www.revenuecat.com/api-v1#tag/transactions/operation/defer-a-google-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 |

:::info\[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).
:::

### RevenueCat Billing

RevenueCat Billing subscriptions can be extended using the [Extend a Subscription's Billing Period](https://www.revenuecat.com/api-v2#tag/Subscription/operation/extend-subscription) endpoint.

| Considerations        | Details                                                           |
| :-------------------- | :---------------------------------------------------------------- |
| Extension method      | Specify `extend_by_days` or set a new `extend_until_ms` timestamp |
| Maximum extension     | 90 days per request                                               |
| Extensions per year   | No limit                                                          |
| Customer notification | No automatic notification is sent to the customer                 |

**Parameters (one required):**

| Parameter         | Required                               | Type    | Description                                           |
| :---------------- | :------------------------------------- | :------ | :---------------------------------------------------- |
| `extend_by_days`  | ✅ Yes, if not using `extend_until_ms` | integer | Number of days to extend (1–90).                      |
| `extend_until_ms` | ✅ Yes, if not using `extend_by_days`  | integer | New expiration timestamp in milliseconds since epoch. |

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

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

```json
{
  "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:

```ts
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 RevenueCat 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

```ts
// 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

```ts
// 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:

```ts
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](https://www.revenuecat.com/api-v1#tag/customer_info_model)
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](https://www.revenuecat.com/api-v1#tag/customers/operation/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](https://www.revenuecat.com/api-v1#tag/entitlements/operation/grant-a-promotional-entitlement) 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

```typescript
if (event.environment === "SANDBOX") {
  console.log("Sandbox purchase - skipping promotional extension");
  return;
}
```

## Related Resources

- [Customer Profile - Extending Subscription Renewal Dates](https://www.revenuecat.com/docs/dashboard-and-metrics/customer-profile#extending-subscription-renewal-dates)
- [RevenueCat Billing: Managing Customer Subscriptions](https://www.revenuecat.com/docs/web/web-billing/managing-customer-subscriptions)
- [Webhooks Overview](https://www.revenuecat.com/docs/integrations/webhooks)
- [Webhook Event Types and Fields](https://www.revenuecat.com/docs/integrations/webhooks/event-types-and-fields)
- [Apple: Extend a Subscription](https://www.revenuecat.com/api-v1#tag/transactions/operation/extend-an-apple-subscription)
- [Google: Defer a Subscription](https://www.revenuecat.com/api-v1#tag/transactions/operation/defer-a-google-subscription)
- [RevenueCat Billing: Extend a Subscription's Billing Period](https://www.revenuecat.com/api-v2#tag/Subscription/operation/extend-subscription)
- [Grant an Entitlement](https://www.revenuecat.com/api-v1#tag/entitlements/operation/grant-a-promotional-entitlement)
