openapi: 3.1.0
info:
  title: External Purchases API (BETA)
  version: '0.1'
x-revenuecat-hidden: true
servers:
  - url: https://api.revenuecat.com/
x-tagGroups:
  - name: General
    tags:
      - Introduction
      - Example Subscription Lifecycle
      - 'Deep Dive Guide: Wiring Your External Subscription Step by Step'
      - Backfill Logic
      - Beta Limitations
  - name: Endpoint Reference
    tags:
      - External Purchases
tags:
  - name: Introduction
    x-traitTag: true
    description: >
      <div class="theme-admonition theme-admonition-info alert alert--warning">
        <div class="heading">Private Beta</div>
        <div>This is a documentation of the External Purchases API, which is currently in private beta.</div>
      </div>

      What is the External Purchases API? When to use it?


      ## Context


      The External Purchases API enables you to track subscription status and
      revenue data from Payment Service Providers that RevenueCat doesn't
      support natively. Such examples of Payment Service Providers, referenced
      as _External Sources_, are Paddle, PayPal, Braintree, Adyen, and Recurly.


      You can post subscription status and payment events to the External
      Purchases API to use RevenueCat as a single source of truth for providing
      access to your application across mobile and web and to consolidate
      revenue data and key subscription metrics. Customer history, webhooks, and
      integration events are supported as well, but they have
      [limitations](#tag/Beta-Limitations) during the Beta period.


      The API expects data to be posted in a format defined in the [API
      reference](#tag/External-Purchases), meaning that you would need to
      convert or transform data from the _External Source_ before posting it to
      the External Purchases API endpoint. RevenueCat accepts data posted to
      this endpoint as is, and relies on your timely requests to provide
      entitlement access.


      If you have a mobile first business with a web presence that follows a
      similar monetization model and subscription lifecycle implementation
      typical to the app stores, then this API is the right choice for you to
      consolidate your stack.


      ## Adding an External Source


      You can add an External Source under _Project > Apps > +New_. All you need
      to provide is the App name. The External API Keys and RevenueCat App ID
      required to call the API are automatically generated after hitting _Save
      Changes_.


      ![](/docs/images/external-purchases-api/adding-external-app.png)


      ![](/docs/images/external-purchases-api/external-app-settings.png)



      ## Authentication


      Authentication for the External Purchases API is achieved by setting the
      `Authorization` header with a valid API secret external key. You'll find
      two main types of API keys in your RevenueCat dashboard: _public
      app-specific_ , _secret_ and _secret app-specific_.


      ```Text Authorization Header

      Authorization: Bearer YOUR_REVENUECAT_APP_SPECIFIC_SECRET_API_KEY

      ```


      The API keys are automatically generated for each External Source whenever
      you add a new _External Source_ on the _Add New App_ screen in the
      Dashboard.


      You can retrieve these API keys both on the _Apps settings_ page and
      _Project settings > API Keys_ page.


      ![](/docs/images/external-purchases-api/external-secret-api-keys.png)


      ## Permissions


      You can call the External Purchases API only with the corresponding
      _app-specific Secret API key_.

      The _app-specific Secret API Key_ should be kept out of any publicly
      accessible areas such as GitHub or client side code.

      Additionally, an _app-specific Public API Key_ is generated so public
      endpoints like
      [Offerings](/docs/api-v1#tag/offerings/operation/get-offerings) can be
      accessed.


      # Base URL


      The base URL for the External Purchases API is
      `https://api.revenuecat.com/`.


      # Endpoints


      The API has one endpoint, `POST/receipts/external`.


      `POST/receipts/external` is used for requests with a single purchase
      and/or payment, and processes them right away. Calling [Get
      Customer](/docs/api-v1#tag/customers/operation/subscribers) right after
      will return the updated entitlements.


      For more details check the [API
      reference](#operation/track-external-purchases).


      ## Payload


      The body of the `POST` requests should be encoded in JSON and have the
      'Content-Type' header set to 'application/json'.


      ```Text Content-Type Header

      Content-Type: application/json

      ```


      ```json Sample body

      {
        "purchase": {
          "object": "external_subscription",
          "customer_id": "app_user_id12341234",
          "source_subscription_identifier": "paddle_sub_id1234",
          "source_product_identifier": "paddle_product_id1234",
          "updated_at": "2023-04-01T00:00:00",
          "current_period_starts_at": "2023-04-01T00:00:00",
          "current_period_ends_at": "2023-05-01T00:00:00",
          "gives_access": true,
          "status": "active",
          "environment": "production",
          "auto_renewal_status": "will_renew"
        },
        "payment": {
          "object": "external_subscription_payment",
          "source_subscription_identifier": "paddle_sub_id1234",
          "payment_identifier": "payment_id1234",
          "processed_at": "2023-04-01T00:00:00",
          "amount_in_local_currency": {
            "gross": 9.99,
            "currency": "USD"
          }
        }
      }

      ```
  - name: Example Subscription Lifecycle
    x-traitTag: true
    description: >
      This example of a subscription lifecycle demonstrates the series of API
      request to make to the External Purchases API and the resulting events
      generated (for Customer History, webhooks, and integration events).


      This flow covers a Trial Flow with Successful Conversion, an ordinary
      Subscription Renewal, a Billing Issue Flow that gets resolved in Grace
      Period, and eventually a Cancellation Flow.


      ## Sample API requests


      ### Trial purchase

      ```json Trial purchase

      {
        "purchase": {
          "object": "external_subscription",
          "customer_id": "app_user_id12341234",
          "source_subscription_identifier": "paddle_sub_id1234",
          "source_product_identifier": "paddle_product_id1234",
          "updated_at": "2023-03-01T00:00:00",
          "current_period_starts_at": "2023-03-01T00:00:00",
          "current_period_ends_at": "2023-04-01T00:00:00",
          "gives_access": true,
          "status": "trialing",
          "environment": "production"
        },
        "payment": null
      }

      ```

      ### Trial conversion

      ```json Trial conversion

      {
        "purchase": {
          "object": "external_subscription",
          "customer_id": "app_user_id12341234",
          "source_subscription_identifier": "paddle_sub_id1234",
          "source_product_identifier": "paddle_product_id1234",
          "updated_at": "2023-04-01T00:00:00",
          "current_period_starts_at": "2023-04-01T00:00:00",
          "current_period_ends_at": "2023-05-01T00:00:00",
          "gives_access": true,
          "status": "active",
          "environment": "production",
          "auto_renewal_status": "will_renew"
        },
        "payment": {
          "object": "external_subscription_payment",
          "source_subscription_identifier": "paddle_sub_id1234",
          "payment_identifier": "payment_id1234",
          "processed_at": "2023-04-01T00:00:00",
          "amount_in_local_currency": {
            "gross": 9.99,
            "currency": "USD"
          }
        }
      }

      ```

      ### Renewal

      ```json Renewal

      {
        "purchase": {
          "object": "external_subscription",
          "customer_id": "app_user_id12341234",
          "source_subscription_identifier": "paddle_sub_id1234",
          "source_product_identifier": "paddle_product_id1234",
          "updated_at": "2023-05-01T00:00:00",
          "current_period_starts_at": "2023-05-01T00:00:00",
          "current_period_ends_at": "2023-06-01T00:00:00",
          "gives_access": true,
          "status": "active",
          "environment": "production",
          "auto_renewal_status": "will_renew"
        },
        "payment": {
          "object": "external_subscription_payment",
          "source_subscription_identifier": "paddle_sub_id1234",
          "payment_identifier": "payment_id2345",
          "processed_at": "2023-05-01T00:00:00",
          "amount_in_local_currency": {
            "gross": 9.99,
            "currency": "USD"
          }
        }
      }

      ```

      ### Billing issue

      ```json Billing issue

      {
        "purchase": {
          "object": "external_subscription",
          "customer_id": "app_user_id12341234",
          "source_subscription_identifier": "paddle_sub_id1234",
          "source_product_identifier": "paddle_product_id1234",
          "updated_at": "2023-06-01T00:00:00",
          "current_period_starts_at": "2023-06-01T00:00:00",
          "current_period_ends_at": "2023-06-14T00:00:00",
          "gives_access": true,
          "status": "in_grace_period",
          "environment": "production",
          "auto_renewal_status": "will_not_renew"
        },
        "payment": null
      }

      ```

      ### Billing succeeds

      ```json Billing succeeds

      {
        "purchase": {
          "object": "external_subscription",
          "customer_id": "app_user_id12341234",
          "source_subscription_identifier": "paddle_sub_id1234",
          "source_product_identifier": "paddle_product_id1234",
          "updated_at": "2023-06-12T00:00:00",
          "current_period_starts_at": "2023-06-01T00:00:00",
          "current_period_ends_at": "2023-07-01T00:00:00",
          "gives_access": true,
          "status": "active",
          "environment": "production",
          "auto_renewal_status": "will_renew"
        },
        "payment": {
          "object": "external_subscription_payment",
          "source_subscription_identifier": "paddle_sub_id3456",
          "payment_identifier": "payment_id1234",
          "processed_at": "2023-06-12T00:00:00",
          "amount_in_local_currency": {
            "gross": 9.99,
            "currency": "USD"
          }
        }
      }

      ```

      ### Cancellation

      ```json Cancellation

      {
        "purchase": {
          "object": "external_subscription",
          "customer_id": "app_user_id12341234",
          "source_subscription_identifier": "paddle_sub_id1234",
          "source_product_identifier": "paddle_product_id1234",
          "updated_at": "2023-06-18T00:00:00",
          "current_period_starts_at": "2023-06-01T00:00:00",
          "current_period_ends_at": "2023-07-01T00:00:00",
          "gives_access": true,
          "status": "active",
          "environment": "production",
          "auto_renewal_status": "will_not_renew"
        },
        "payment": null
      }

      ```

      ### Expiration

      ```json Expiration

      {
        "purchase": {
          "object": "external_subscription",
          "customer_id": "app_user_id12341234",
          "source_subscription_identifier": "paddle_sub_id1234",
          "source_product_identifier": "paddle_product_id1234",
          "updated_at": "2023-07-01T00:00:00",
          "current_period_starts_at": "2023-06-01T00:00:00",
          "current_period_ends_at": "2023-07-01T00:00:00",
          "gives_access": false,
          "status": "expired",
          "environment": "production",
          "auto_renewal_status": "will_not_renew"
        },
        "payment": null
      }

      ```


      > Note: Make sure to include the `current_period_starts_at` for the
      transaction you are trying to expire. Otherwise your target transaction
      might not be found by the system.


      ## Webhook events flow


      ![](/docs/images/external-purchases-api/b310775-Screenshot_2023-08-03_at_16.36.49.png)


      ![](/docs/images/external-purchases-api/0ac4f83-Screenshot_2023-08-03_at_16.16.32.png)
  - name: 'Deep Dive Guide: Wiring Your External Subscription Step by Step'
    x-traitTag: true
    description: >
      This is a self-contained, end-to-end guide that takes you from zero to a
      correct backend integration. It expands on the [Example Subscription
      Lifecycle](#tag/Example-Subscription-Lifecycle) by explaining *how the
      system works underneath*, *which clusters of real-world events you must
      model*, and *exactly what each one looks like on the wire*.


      By the end you should understand the mental model behind the External
      Purchases API and have a concrete checklist of what your backend has to
      send, in what order, and what RevenueCat will do with it.


      > This guide reuses the payloads from the [Example Subscription
      Lifecycle](#tag/Example-Subscription-Lifecycle) and adds new ones for the
      flows that example doesn't cover (extensions, transfers, refunds, product
      changes, and split-billing edge cases).


      ---


      ## 1. How the system works (the mental model)


      The API surface is intentionally small: you `POST /v1/receipts/external`
      with a body containing a `purchase` object, a `payment` object, or both.
      Everything else — Customer History, webhooks, and integration events — is
      derived from the stream of requests you send. RevenueCat accepts your data
      **as-is** and relies on your timely requests to grant and revoke
      entitlement access.


      ### The four objects


      There are two object types for subscriptions and two for one-time
      purchases:


      | Concept | Subscriptions | One-time purchases |

      |---|---|---|

      | The thing that grants access for a period of time |
      `external_subscription` | `external_purchase` |

      | The money movement (charge or refund) | `external_subscription_payment`
      | `external_purchase_payment` |


      Throughout this guide we use the subscription objects, but the same rules
      apply to one-time purchases except where called out.


      ### Transactions vs. payments


      Two ideas are often conflated but are distinct:


      - A **transaction** represents a *period of access*: it has a start, an
      end, a status, and whether it currently `gives_access`. In the payload
      this is the `purchase` object (`external_subscription`).

      - A **payment** represents a *money movement* in time: a charge or a
      refund that happened at a specific instant. In the payload this is the
      `payment` object (`external_subscription_payment`).


      A subscription is therefore a *chain of transactions* (one per period:
      trial, first paid period, each renewal) with *payments attached to
      whichever period contains them*.


      ```Text Object model

      external_subscription (source_subscription_identifier =
      "paddle_sub_id1234")

      │

      ├─ period 1  [2023-03-01 → 2023-04-01]  status: trialing      ←
      transaction

      ├─ period 2  [2023-04-01 → 2023-05-01]  status: active        ←
      transaction

      │     └─ payment_id1234  processed_at 2023-04-01  $9.99        ← payment

      └─ period 3  [2023-05-01 → 2023-06-01]  status: active        ←
      transaction
            └─ payment_id2345  processed_at 2023-05-01  $9.99        ← payment
      ```


      ### Transactions are mutable, events are immutable (the generic RevenueCat
      model)


      This model is **not specific to External Purchases** — it is how
      RevenueCat models every store (App Store, Google Play, Stripe, Amazon, and
      others). The External Purchases API is another way to feed transactions
      into this same machinery, so what you wire up here behaves exactly like a
      native store integration downstream.


      - **Transactions are mutable.** A transaction is the current, *living*
      state of a period of access. As reality changes — a trial converts, a
      renewal succeeds, a billing issue starts, a user cancels — you keep
      updating the *same* transaction (same period boundaries, newer
      `updated_at`). At any moment a transaction reflects the latest truth
      you've told us.

      - **Events are immutable.** Every meaningful change to a transaction
      produces an **Event**, an append-only, point-in-time record of *what
      happened* (for example `INITIAL_PURCHASE`, `RENEWAL`, `CANCELLATION`,
      `BILLING_ISSUE`, `EXPIRATION`). Events are never edited or deleted;
      correcting a mistake means a *new* event, not a rewrite of an old one.
      Together they form the immutable history of the subscription that you see
      in [Customer History](/docs/dashboard-and-metrics/customer-history).

      - **Dispatchable events fan out to webhooks and integrations.** Once an
      event is generated, if it is *dispatchable* RevenueCat delivers it to your
      configured destinations: it fires a [webhook](/docs/integrations/webhooks)
      and triggers each enabled
      [integration](/docs/integrations/third-party-integrations) (analytics,
      attribution, data warehouses, and more).


      ```Text Flow

      your POST  ──►  transaction (mutable state)  ──►  event (immutable record)
                                                            │
                                                            └─ if dispatchable ──►  webhooks
                                                                                    integrations
                                                                                    Customer History
      ```


      In summary: you are responsible for keeping the *transaction* accurate and
      timely. RevenueCat turns your sequence of transaction states into the
      *events* that everything else — entitlements, webhooks, integrations, and
      metrics — is built on. Not every change is dispatchable (see the [Beta
      Limitations](#tag/Beta-Limitations) for cases where no event is generated
      today), but the model is always transaction-first, event-derived.


      ### How transactions are identified


      You never set a transaction id yourself. You provide:


      - `source_subscription_identifier`: the id of the subscription in your
      external source. **All renewals of the same subscription must reuse the
      same value.** This is what ties periods together into one chain.

      - `current_period_starts_at` / `current_period_ends_at`: the boundaries of
      the period.

      - `updated_at`: the moment this status was true (used to resolve
      out-of-order delivery).


      RevenueCat derives an internal transaction identifier from these.
      Conceptually, the first period acts as the *original* transaction, and
      each subsequent period is keyed off that original plus the period's update
      timestamp (roughly `original_transaction_id..updated_at_timestamp`). You
      will see this derived identifier in [data
      exports](/docs/integrations/data-export) and integration payloads, but you
      do not send it. Your job is only to keep `source_subscription_identifier`
      stable and the period boundaries correct.


      ### How payments get matched to a transaction


      Payments are matched to transactions by a single rule:


      > A payment is matched to a transaction **purely by time**, using the
      payment's `source_subscription_identifier` and `processed_at`. RevenueCat
      scans that subscription's periods **latest-ending first** and attaches the
      payment to the **latest-ending period whose `current_period_ends_at` is at
      or after `processed_at`**. For a charge, **only the period end is
      checked** (the period *start* is not), so a payment can attach to a period
      even when `processed_at` falls before that period's
      `current_period_starts_at`.


      Consequences of this rule:


      - **Posting a `purchase` and a `payment` in the same body creates no
      special link between them.** They are not "bundled". The `payment` is
      still placed by its `processed_at`, exactly as if it had been posted
      separately.

      - **A single transaction can have many payments attached** (see the
      split-billing edge case below).

      - **Only the period end bounds a charge.** Because the start is not
      checked and periods are scanned latest-ending first, a charge lands on the
      newest period that ends at or after `processed_at`. When two periods
      overlap, you cannot target the older one with a charge.

      - **Once a payment is processed and attached, it cannot be relocated or
      edited.** Sending a corrected period later will not move a payment that
      was already matched. Refunds are therefore modeled as *new* payment
      objects, never as edits (see Refunds).

      - **Refunds can be matched more strictly.** When *strict refund matching*
      is enabled for your project, a refund (a negative payment) is matched only
      to the period whose **full window** contains it (`current_period_starts_at
      <= processed_at <= current_period_ends_at`, both bounds checked) rather
      than by the end alone. This changes which period a refund or credit lands
      on, so confirm with the RevenueCat team whether it is enabled for your
      account.

      - Payments are deduplicated by `payment_identifier`, so it is safe to
      retry. Give refunds a `payment_identifier` distinct from the original
      charge.


      <div class="theme-admonition theme-admonition-info alert alert--info">
        <div class="heading">Late-arriving commissions and taxes</div>
        <div>Some payment providers report <strong>commissions and taxes after</strong> the initial charge, so those amounts aren't known when the payment is first processed. Today, the recommended approach is to post the <code>payment</code> object once that information is available, populating the <code>tax</code> and <code>commission</code> fields of <code>amount_in_local_currency</code> (or <code>amount_in_usd</code>) so the recorded revenue is complete. We're actively working on a better solution for late-arriving commission and tax data.</div>
      </div>


      ### How to modify an existing transaction


      To update a period that you already posted (for example, to change its
      status or extend its end date), post the `purchase` again with:


      - The **same** `source_subscription_identifier`.

      - **Boundaries that let RevenueCat locate the existing period.**
      RevenueCat matches your update to an existing period when **either
      boundary is identical** to the stored period: the same
      `current_period_starts_at` **or** the same `current_period_ends_at` (the
      end also matches a stored grace-period deadline). Because the match needs
      at least one unchanged boundary, **keep the boundary you are not changing
      identical, down to the millisecond** — when you change the end (for
      example, an extension) keep `current_period_starts_at` identical so the
      match falls back to the start, and when the end stays put the same
      `current_period_ends_at` targets the period even if
      `current_period_starts_at` differs. If neither boundary matches an
      existing period, RevenueCat creates a separate, overlapping transaction
      instead of an edit.

      - An `updated_at` that is **strictly later** than the last `updated_at`
      you posted for that period. Older-or-equal updates are ignored so that
      out-of-order delivery never overwrites fresher state.


      For **one-time purchases** (`external_purchase`) the rule on `updated_at`
      is different: because a one-time purchase has no period to extend, its
      `updated_at` doubles as its `start_at`. To target the same purchase for an
      edit, `updated_at` must be **exactly equal** to the original, not later.


      ### Other things worth knowing up front


      - You can attach a `metadata` object (key-value pairs of
      string/number/boolean/null) to carry your own context. Metadata lives at
      the **subscription / purchase level**, not at the individual transaction
      (period) level, so it describes the whole subscription rather than a
      single renewal period. This capability is **not enabled by default**,
      contact the RevenueCat team to have it turned on for your account.

      - Payment objects can appear in [data
      exports](/docs/integrations/data-export), so the `payment_identifier`,
      `processed_at`, and amounts you send are what your finance/analytics will
      see. Including payment objects in exports is **not enabled by default**,
      contact the RevenueCat team to have it enabled for your account.

      - `EXPIRATION` events are **not** fired automatically. When a subscription
      lapses you must explicitly post an `expired` status (see Cancellation &
      expiration).


      ### Live updates vs. historical imports (the 30-day window)


      RevenueCat distinguishes between *live* activity and *historical* data,
      and this distinction is also generic across stores:


      > Transactions whose relevant timestamp is **older than 30 days** are
      treated as a **backfill / historical import** rather than a live update.


      Historical imports are recorded to build accurate state, history, and
      metrics, but they do **not** generate dispatchable events, so they will
      **not** fire webhooks or trigger integrations. This is intentional: when
      you first onboard, or replay months of past subscriptions, you don't want
      to flood your downstream systems with thousands of retroactive
      notifications.


      What this means in practice:


      - For an ongoing, real-time integration, post updates promptly (well
      within 30 days of when they happen) so they are treated as live and fan
      out to webhooks/integrations.

      - For an initial migration of existing subscribers, expect those older
      records to land silently as backfill. They will still grant entitlements
      and appear in [Customer
      History](/docs/dashboard-and-metrics/customer-history) and exports.

      - See [Backfill Logic](#tag/Backfill-Logic) for how out-of-order and
      overlapping historical periods are reconciled.


      ---


      ## 2. Modeling the subscription lifecycle


      Real subscription systems are just combinations of a handful of patterns.
      Model these and you've covered the lifecycle. Each cluster below tells you
      **when it happens**, **whether it creates a new transaction or edits an
      existing one**, **the payload**, and **the resulting events**.


      Two families of clusters:


      1. **Generators** create a brand-new period (a new row): new purchases,
      trials, and renewals.

      2. **Mutators** edit an existing period (same period boundaries, later
      `updated_at`): extensions, status changes, cancellations, expirations,
      refunds, transfers, and product changes.


      ### 2.1 New purchases, trials, and renewals (generators)


      These each represent a *completely new period of time*, so each one is a
      new transaction with a new `current_period_starts_at` /
      `current_period_ends_at`.


      A free trial starts the chain. There is no payment yet, so `payment` is
      `null`. This fires an `INITIAL_PURCHASE` event.


      ```json Trial purchase

      {
        "purchase": {
          "object": "external_subscription",
          "customer_id": "app_user_id12341234",
          "source_subscription_identifier": "paddle_sub_id1234",
          "source_product_identifier": "paddle_product_id1234",
          "updated_at": "2023-03-01T00:00:00",
          "current_period_starts_at": "2023-03-01T00:00:00",
          "current_period_ends_at": "2023-04-01T00:00:00",
          "gives_access": true,
          "status": "trialing",
          "environment": "production"
        },
        "payment": null
      }

      ```


      When the trial converts, you open a new paid period and attach the first
      charge. Note the `payment.processed_at` (`2023-04-01`) falls inside the
      new period, which is how it gets matched. This fires an
      `INITIAL_PURCHASE`/`RENEWAL` style event with revenue.


      ```json Trial conversion

      {
        "purchase": {
          "object": "external_subscription",
          "customer_id": "app_user_id12341234",
          "source_subscription_identifier": "paddle_sub_id1234",
          "source_product_identifier": "paddle_product_id1234",
          "updated_at": "2023-04-01T00:00:00",
          "current_period_starts_at": "2023-04-01T00:00:00",
          "current_period_ends_at": "2023-05-01T00:00:00",
          "gives_access": true,
          "status": "active",
          "environment": "production",
          "auto_renewal_status": "will_renew"
        },
        "payment": {
          "object": "external_subscription_payment",
          "source_subscription_identifier": "paddle_sub_id1234",
          "payment_identifier": "payment_id1234",
          "processed_at": "2023-04-01T00:00:00",
          "amount_in_local_currency": {
            "gross": 9.99,
            "currency": "USD"
          }
        }
      }

      ```


      Each renewal is the same shape: a new period that starts where the
      previous one ended, plus the renewal charge. Keep
      `source_subscription_identifier` stable and give the payment a fresh
      `payment_identifier`. This fires a `RENEWAL` event.


      ```json Renewal

      {
        "purchase": {
          "object": "external_subscription",
          "customer_id": "app_user_id12341234",
          "source_subscription_identifier": "paddle_sub_id1234",
          "source_product_identifier": "paddle_product_id1234",
          "updated_at": "2023-05-01T00:00:00",
          "current_period_starts_at": "2023-05-01T00:00:00",
          "current_period_ends_at": "2023-06-01T00:00:00",
          "gives_access": true,
          "status": "active",
          "environment": "production",
          "auto_renewal_status": "will_renew"
        },
        "payment": {
          "object": "external_subscription_payment",
          "source_subscription_identifier": "paddle_sub_id1234",
          "payment_identifier": "payment_id2345",
          "processed_at": "2023-05-01T00:00:00",
          "amount_in_local_currency": {
            "gross": 9.99,
            "currency": "USD"
          }
        }
      }

      ```


      ### 2.2 Operations that edit an existing transaction


      Everything in this family targets a period you already posted. The rule:
      **same `source_subscription_identifier`**, boundaries that resolve to the
      existing period (an identical `current_period_starts_at` **or**
      `current_period_ends_at`, per **How to modify an existing transaction**
      above), and a **strictly-later `updated_at`**.


      #### Subscription extended


      Use this when the access period gets longer without a new charge, for
      example a goodwill extension or a promo. You re-post the same period with
      a later `current_period_ends_at` and a later `updated_at`. The
      `current_period_starts_at` stays identical so RevenueCat edits the
      existing period instead of creating a new one.


      ```json Subscription extended

      {
        "purchase": {
          "object": "external_subscription",
          "customer_id": "app_user_id12341234",
          "source_subscription_identifier": "paddle_sub_id1234",
          "source_product_identifier": "paddle_product_id1234",
          "updated_at": "2023-05-10T00:00:00",
          "current_period_starts_at": "2023-05-01T00:00:00",
          "current_period_ends_at": "2023-06-15T00:00:00",
          "gives_access": true,
          "status": "active",
          "environment": "production",
          "auto_renewal_status": "will_renew"
        },
        "payment": null
      }

      ```


      <div class="theme-admonition theme-admonition-info alert alert--warning">
        <div class="heading">Match current_period_starts_at exactly</div>
        <div>Because an extension changes the period end, the end can no longer serve as the match key, so the match <strong>falls back to <code>current_period_starts_at</code></strong> (the extension branch of the rule described under <strong>How to modify an existing transaction</strong>). That value must match the target period <strong>exactly, down to the millisecond</strong>. If it differs even slightly, RevenueCat treats the request as a different period and can create an overlapping or duplicate transaction instead of extending the existing one.</div>
      </div>


      <div class="theme-admonition theme-admonition-info alert alert--info">
        <div class="heading">This emits a SUBSCRIPTION_EXTENDED event</div>
        <div>Moving <code>current_period_ends_at</code> to a later date emits a <code>SUBSCRIPTION_EXTENDED</code> event and updates the entitlement expiry. This holds even when the previous end date was already in the future and you move it to a different future date.</div>
      </div>


      #### Purchase transfer


      A transfer moves a subscription from one `app_user_id` to another (for
      example, when a user signs into a different account). **Transfers are not
      performed through the External Purchases API**, and you should not try to
      move a subscription by changing `customer_id` in the payload. Instead, use
      the Developer API v2 [Transfer a customer's subscriptions and one-time
      purchases](/docs/api-v2#tag/Customer/operation/transfer-customer-data)
      endpoint, which moves all of a source customer's purchases (optionally
      filtered by `app_ids`) to a target customer and generates the
      corresponding `TRANSFER` event.


      <div class="theme-admonition theme-admonition-info alert alert--warning">
        <div class="heading">Posting a different customer_id returns an error</div>
        <div>You cannot move a subscription by changing <code>customer_id</code> in the payload. If you post a <code>purchase</code> whose <code>customer_id</code> resolves to a different customer than the one that already owns the subscription, the request is <strong>rejected with an error</strong> (the purchase belongs to a different customer) rather than silently reassigned. Use the v2 transfer endpoint above to move a subscription between customers.</div>
      </div>


      #### Refund and partial refunds


      A refund is **not** an edit to a transaction or to the original payment.
      It is a **new payment object** with a negative amount and a distinct
      `payment_identifier`. RevenueCat matches it to a period by its
      `processed_at`, just like a charge.


      A full refund negates the original gross amount:


      ```json Full refund

      {
        "purchase": null,
        "payment": {
          "object": "external_subscription_payment",
          "source_subscription_identifier": "paddle_sub_id1234",
          "payment_identifier": "refund_id1234",
          "processed_at": "2023-05-05T00:00:00",
          "amount_in_local_currency": {
            "gross": -9.99,
            "currency": "USD"
          }
        }
      }

      ```


      A partial refund negates only part of it:


      ```json Partial refund

      {
        "purchase": null,
        "payment": {
          "object": "external_subscription_payment",
          "source_subscription_identifier": "paddle_sub_id1234",
          "payment_identifier": "refund_id2345",
          "processed_at": "2023-05-06T00:00:00",
          "amount_in_local_currency": {
            "gross": -4.00,
            "currency": "USD"
          }
        }
      }

      ```


      If the refund should also revoke access, post a `purchase` update in the
      same family that sets `gives_access: false` and an appropriate status.


      > Note on refund events: A **partial refund does not trigger a refund
      event** for subscriptions — it only adjusts the recorded revenue. For
      **one-time purchases**, the refund is reflected by the purchase's `status`
      changing to `refunded` (the `external_purchase` `status` field), not by
      the refund payment object on its own.


      <div class="theme-admonition theme-admonition-info alert alert--warning">
        <div class="heading">Beta limitation</div>
        <div>When your payment provider retains a commission on a refund, RevenueCat does not track negative commissions, so you may see a discrepancy equal to the retained commission (see <a href="#tag/Beta-Limitations">Beta Limitations</a> #4).</div>
      </div>


      ### 2.3 Billing issues: grace, retry, on-hold, and recovery


      This is the deepest of the mutator clusters. It walks through declaring a
      **billing issue / grace period**, then either **recovering**, dropping
      into a **billing-retry / on-hold** state (access suspended but still
      recoverable), or reaching **final expiration**, and which events each
      transition produces.


      **These updates act on the existing period, the one that is due to expire
      now.** You never create separate "grace" or "on-hold" records. A
      subscription is a series of periods (transactions), and billing state is
      expressed as an *overlay on the current period*: post the same
      `source_subscription_identifier`, boundaries that resolve to that period,
      and a strictly-later `updated_at`. The one exception is **recovery**,
      which starts a *new* period (a renewal).


      You control that overlay with three signals in the `purchase` object:


      | What you send | What it controls | Resulting event(s) |

      |---|---|---|

      | `status` | Whether the subscription reads as `in_grace_period`,
      `in_billing_retry`, or `expired` | `BILLING_ISSUE` / `EXPIRATION` |

      | `gives_access` | The entitlement. Sending `false` clamps
      `current_period_ends_at` to the update time, so access ends immediately |
      (no event on its own) |

      | `auto_renewal_status` | Whether the subscription is still set to renew |
      `CANCELLATION` / `UNCANCELLATION` |


      Two rules explain every transition below:


      - **Access follows `gives_access`.** Sending `gives_access: false` moves
      the current period end to the update time, so the entitlement ends
      immediately; `true` keeps (or extends) access.

      - **A new period means a renewal.** `RENEWAL` (or `INITIAL_PURCHASE`)
      fires only when a *new* period is created (`current_period_starts_at`
      advances). Updating billing state on the current period never produces a
      renewal.


      The examples below continue the subscription `paddle_sub_id1234` from the
      sections above, whose current paid period runs `2023-06-01` ->
      `2023-07-01`.


      #### Billing issue and grace period (access retained)


      The renewal charge on `2023-07-01` fails, but you want to keep access
      while the provider retries. Send `status: in_grace_period` with
      `gives_access: true`, and set `current_period_ends_at` to the **grace
      deadline** (keep `current_period_starts_at` on the existing period so this
      extends it rather than starting a new one).


      ```json Billing issue and grace period

      {
        "purchase": {
          "object": "external_subscription",
          "customer_id": "app_user_id12341234",
          "source_subscription_identifier": "paddle_sub_id1234",
          "source_product_identifier": "paddle_product_id1234",
          "updated_at": "2023-07-01T00:05:00",
          "current_period_starts_at": "2023-06-01T00:00:00",
          "current_period_ends_at": "2023-07-08T00:00:00",
          "gives_access": true,
          "status": "in_grace_period",
          "environment": "production",
          "auto_renewal_status": "will_renew"
        },
        "payment": null
      }

      ```


      **Event:** `BILLING_ISSUE` (fires **once** per episode). **State:**
      `in_grace_period`, access retained until the grace deadline.


      > If the provider also reports the subscription will not renew, send
      `auto_renewal_status: "will_not_renew"`. You then get `BILLING_ISSUE`
      **and** `CANCELLATION`, mirroring a store-style cancellation with a
      billing-error reason.


      #### Dynamic or unknown retry windows


      If your payment provider uses a dynamic billing-retry schedule (for
      example, Adyen), you often won't know the exact grace or on-hold end date
      in advance. That's fine — you can keep rolling `current_period_ends_at`
      forward on each retry signal while `status` stays `in_grace_period`. This
      only moves the grace deadline and is **event-silent**: it does not re-fire
      `BILLING_ISSUE`, and it never triggers `SUBSCRIPTION_EXTENDED` (that event
      tracks the *paid* period end, not the grace deadline). Shortening the
      value again later is allowed too.


      **Keep the horizon short, never push grace past where the next period
      would begin.** A subscription's entitlement expiry is the latest period
      end across all of its periods (a period's grace deadline counts as that
      period's end). If the subscription later recovers, the new paid period is
      created but the previous period's grace deadline is **not** trimmed
      automatically. A stale grace deadline that reaches beyond the recovered
      period can therefore keep the entitlement alive past when it should end.
      With a conventional short grace window this can't happen (grace ends a few
      days after the paid period, well before the next full cycle); it only
      becomes reachable when you roll the horizon far out.


      **Correct it before recovery, not after.** If you did extend the horizon,
      shorten `current_period_ends_at` back to at most the start of the
      recovering period in an update that still targets the current period, and
      only then post the new `active` period. Editing an older period's grace
      deadline once a newer period already exists is not supported (period
      corrections only adjust a period's start/end, and normal updates act on
      the latest period). Prevention is much simpler than correction.


      **Prefer on hold when access should be suspended.** If you don't need to
      grant access during retries, model the retry window as `status:
      in_billing_retry` with `gives_access: false` instead of grace. It carries
      no end date to manage, so there is nothing to roll or shorten: the
      subscription stays recoverable (keep `auto_renewal_status: will_renew`)
      until you post a renewal or an `expired` state.


      #### Recovery: the payment succeeds (renewal)


      The retry succeeds. Post a **new period** (`current_period_starts_at` =
      the previous paid end) with `status: active`. Because a new period is
      created, this is a renewal and the billing-issue flag clears
      automatically. Optionally attach a `payment` to record the recovered
      revenue.


      ```json Recovery (renewal)

      {
        "purchase": {
          "object": "external_subscription",
          "customer_id": "app_user_id12341234",
          "source_subscription_identifier": "paddle_sub_id1234",
          "source_product_identifier": "paddle_product_id1234",
          "updated_at": "2023-07-03T00:00:00",
          "current_period_starts_at": "2023-07-01T00:00:00",
          "current_period_ends_at": "2023-08-01T00:00:00",
          "gives_access": true,
          "status": "active",
          "environment": "production",
          "auto_renewal_status": "will_renew"
        },
        "payment": {
          "object": "external_subscription_payment",
          "source_subscription_identifier": "paddle_sub_id1234",
          "payment_identifier": "payment_id3456",
          "processed_at": "2023-07-03T00:00:00",
          "amount_in_local_currency": {
            "gross": 9.99,
            "currency": "USD"
          }
        }
      }

      ```


      **Event:** `RENEWAL`. **State:** `active`, access continues.


      #### On hold: grace ends, still retrying (access suspended)


      Grace has run out but you keep trying to collect and want the subscription
      to stay *recoverable* (not terminated). Send `status: in_billing_retry`
      with `gives_access: false`, and keep `auto_renewal_status: will_renew`.


      ```json On hold (billing retry)

      {
        "purchase": {
          "object": "external_subscription",
          "customer_id": "app_user_id12341234",
          "source_subscription_identifier": "paddle_sub_id1234",
          "source_product_identifier": "paddle_product_id1234",
          "updated_at": "2023-07-08T00:00:00",
          "current_period_starts_at": "2023-06-01T00:00:00",
          "current_period_ends_at": "2023-07-08T00:00:00",
          "gives_access": false,
          "status": "in_billing_retry",
          "environment": "production",
          "auto_renewal_status": "will_renew"
        },
        "payment": null
      }

      ```


      **Event:** none if `BILLING_ISSUE` already fired during grace (it is
      de-duplicated); a single `BILLING_ISSUE` if you jump here straight from
      `active`. **State:** access suspended, but `will_renew` keeps it eligible
      for recovery.


      > **Mechanics to know:** `gives_access: false` moves
      `current_period_ends_at` to the update time, so the entitlement drops
      immediately. The `will_renew` flag (not a distinct "on-hold" record) is
      what marks the subscription as still-retrying rather than closed.
      Recovering from on hold is identical to the recovery step above: post a
      new `active` period to fire `RENEWAL`.


      #### Termination: retries exhausted (expiration)


      When retries are exhausted (or the grace / on-hold window closes with no
      payment), close the subscription with `status: expired` and `gives_access:
      false`. This is the same terminal step described in **Cancellation and
      expiration** below.


      ```json Termination (expiration)

      {
        "purchase": {
          "object": "external_subscription",
          "customer_id": "app_user_id12341234",
          "source_subscription_identifier": "paddle_sub_id1234",
          "source_product_identifier": "paddle_product_id1234",
          "updated_at": "2023-07-15T00:00:00",
          "current_period_starts_at": "2023-06-01T00:00:00",
          "current_period_ends_at": "2023-07-15T00:00:00",
          "gives_access": false,
          "status": "expired",
          "environment": "production",
          "auto_renewal_status": "will_not_renew"
        },
        "payment": null
      }

      ```


      **Event:** `EXPIRATION`. **State:** `expired`, access removed,
      subscription closed.


      > Note: The `current_period_ends_at` you send on this call matters. If it
      is **later** than the period's previously recorded end (for example,
      because you rolled the grace horizon forward), RevenueCat sees the paid
      period end move forward and can emit a `SUBSCRIPTION_EXTENDED` event
      alongside the expiration; if you **keep the original end date**, no
      extension event is produced. Which behavior is correct is up to you to
      model.


      #### Billing lifecycle at a glance


      | Step | `status` | `gives_access` | `auto_renewal_status` | Event(s) |
      Access |

      |---|---|---|---|---|---|

      | Billing issue + grace | `in_grace_period` | `true` | `will_renew` (or
      `will_not_renew`) | `BILLING_ISSUE` (+ `CANCELLATION`) | granted |

      | Recovery | `active` (new period) | `true` | `will_renew` | `RENEWAL` |
      granted |

      | On hold / billing retry | `in_billing_retry` | `false` | `will_renew` |
      `BILLING_ISSUE` (once, deduped) | suspended |

      | Termination | `expired` | `false` | `will_not_renew` | `EXPIRATION` |
      removed |


      Key semantics:


      - `BILLING_ISSUE` is emitted **once** when the problem is first reported;
      moving between `in_grace_period` and `in_billing_retry` afterwards
      produces no additional billing event.

      - There is **no** dedicated "grace period" or `SUBSCRIPTION_EXTENDED`
      event for grace — grace is signaled by `BILLING_ISSUE` plus the
      `in_grace_period` status. `SUBSCRIPTION_EXTENDED` is reserved for genuine
      *paid*-period extensions.

      - The difference between **grace** and **on hold** is purely
      `gives_access` (`true` vs `false`). The difference between **on hold** and
      **terminated** is `status` / `auto_renewal_status` (`in_billing_retry` +
      `will_renew` = recoverable; `expired` + `will_not_renew` = closed).


      ### 2.4 Upgrades, downgrades, and product changes


      The product is **fixed when a period is created** — you never "change the
      product" on an existing period. Instead, model a product change as **close
      the current period (product A) and open a new period (product B)** on the
      **same** `source_subscription_identifier`, with
      `source_product_identifier` set to product B. Proration and refunds are
      expressed entirely through the payment amounts you post; the period
      structure is always "close A, open B."


      <div class="theme-admonition theme-admonition-info alert alert--warning">
        <div class="heading">Beta limitation</div>
        <div>Product-change events (<code>PRODUCT_CHANGE</code>) are <strong>not generated during the Beta</strong> (see <a href="#tag/Beta-Limitations">Beta Limitations</a> #1.3). A mid-subscription product change surfaces as a <code>RENEWAL</code> webhook, not <code>PRODUCT_CHANGE</code>. The change is still fully visible: you'll see consecutive periods on the same subscription carrying different <code>source_product_identifier</code> values.</div>
      </div>


      #### Keep it one subscription


      - **Same `source_subscription_identifier`** -> the new period is a
      `RENEWAL` and the subscription stays continuous.

      - **Different `source_subscription_identifier`** -> you get an
      `INITIAL_PURCHASE` and a separate subscription. Only do this if it is
      genuinely a new subscription.


      #### Ending the outgoing period


      You have two ways to end period A:


      1. **Let it auto-trim (simplest).** When you post period B as `active`
      with a `current_period_starts_at` earlier than A's end, RevenueCat
      automatically pulls A's end back to B's start to prevent overlap (the same
      reconciliation described in [Backfill Logic](#tag/Backfill-Logic)). You
      don't have to shorten A yourself.

      2. **Shorten it explicitly.** If you need a precise, event-silent trim
      *without* opening a new period, use a period correction that adjusts only
      the period's `current_period_starts_at` / `current_period_ends_at` (it
      emits no purchase events). This matters because a posted period that is a
      strict subset of an existing one is discarded, so shrinking a period must
      be done as a correction rather than a new post.


      #### Payments and refunds


      - Post the `payment` object **after** (or in the same request as) the
      period it belongs to; within one request the period is created first and
      the payment attaches to it.

      - **Matching:** a charge attaches to the **latest-ending period whose
      `current_period_ends_at >= processed_at`** (only the end is checked).
      Because B ends after A, any charge at or before B's end lands on **B** —
      you cannot target the older period A with a charge while B exists. To
      adjust A specifically, use a **refund** (negative payment) with
      `processed_at` inside A's window; that only lands on A when *strict refund
      matching* is enabled for your project, otherwise it follows the same
      latest-ending rule and lands on B.

      - **A refund is a negative `gross`.** A full refund that zeroes the
      period's balance emits a `CANCELLATION` (customer-support reason); a
      partial refund just reduces the recorded amount and fires no event.

      - RevenueCat does **not** compute proration for you. Express it through
      the amounts you post: a prorated charge, and/or a negative payment to
      credit unused time.


      #### Examples


      Baseline: subscription `paddle_sub_id5678` on `paddle_product_basic`
      ($9.99/mo), current period `2023-05-01` -> `2023-06-01`. The change
      happens on `2023-05-15`.


      **1. Upgrade, immediate and prorated.** Open B (`paddle_product_pro`,
      $19.99) on a fresh term starting at the switch time; posting B auto-trims
      A to end `2023-05-15`. Charge the prorated difference inside B's window.


      ```json Upgrade (open the new product period)

      {
        "purchase": {
          "object": "external_subscription",
          "customer_id": "app_user_id12341234",
          "source_subscription_identifier": "paddle_sub_id5678",
          "source_product_identifier": "paddle_product_pro",
          "updated_at": "2023-05-15T00:00:00",
          "current_period_starts_at": "2023-05-15T00:00:00",
          "current_period_ends_at": "2023-06-15T00:00:00",
          "gives_access": true,
          "status": "active",
          "environment": "production",
          "auto_renewal_status": "will_renew"
        },
        "payment": {
          "object": "external_subscription_payment",
          "source_subscription_identifier": "paddle_sub_id5678",
          "payment_identifier": "payment_upgrade_prorated",
          "processed_at": "2023-05-15T00:00:05",
          "amount_in_local_currency": {
            "gross": 14.99,
            "currency": "USD"
          }
        }
      }

      ```


      **Event:** `RENEWAL` (now on `paddle_product_pro`).


      **2. Upgrade, full price and new term.** Identical to #1, but charge the
      full price (`"gross": 19.99`) for a full new term. **Event:** `RENEWAL`.


      **3. Downgrade, deferred to the end of the period (most common).** Leave A
      untouched to run to `2023-06-01`, then post B (`paddle_product_lite`,
      $4.99) starting at A's end. Access is uninterrupted — it's just a renewal
      into the cheaper product.


      ```json Downgrade (deferred at renewal)

      {
        "purchase": {
          "object": "external_subscription",
          "customer_id": "app_user_id12341234",
          "source_subscription_identifier": "paddle_sub_id5678",
          "source_product_identifier": "paddle_product_lite",
          "updated_at": "2023-06-01T00:00:00",
          "current_period_starts_at": "2023-06-01T00:00:00",
          "current_period_ends_at": "2023-07-01T00:00:00",
          "gives_access": true,
          "status": "active",
          "environment": "production",
          "auto_renewal_status": "will_renew"
        },
        "payment": {
          "object": "external_subscription_payment",
          "source_subscription_identifier": "paddle_sub_id5678",
          "payment_identifier": "payment_downgrade",
          "processed_at": "2023-06-01T00:00:05",
          "amount_in_local_currency": {
            "gross": 4.99,
            "currency": "USD"
          }
        }
      }

      ```


      **Event:** `RENEWAL` (on `paddle_product_lite`) at `2023-06-01`.


      **4. Downgrade, immediate with a proration credit.** Open the cheaper B
      now (as in #1, auto-trimming A to end at the switch time), then credit the
      unused time on A with a negative payment whose `processed_at` falls
      **inside A's window** (before the switch time). Because the credit is
      *partial*, no cancellation fires. This credit lands on A rather than B
      only when *strict refund matching* is enabled for your project (see **How
      payments get matched to a transaction** above); without it, the negative
      payment follows the latest-ending rule and attaches to B.


      ```json Credit unused time on the old product (partial refund)

      {
        "purchase": null,
        "payment": {
          "object": "external_subscription_payment",
          "source_subscription_identifier": "paddle_sub_id5678",
          "payment_identifier": "payment_credit_basic",
          "processed_at": "2023-05-14T00:00:00",
          "amount_in_local_currency": {
            "gross": -4.99,
            "currency": "USD"
          }
        }
      }

      ```


      **Event:** `RENEWAL` on the new product (from the period-opening request);
      the negative payment adjusts the old product's revenue without firing a
      cancellation.


      **5. Crossgrade, same price.** Close A and open B at the same price; post
      no payment (or an offsetting `0`). **Event:** `RENEWAL`.


      **6. Full refund / revert of a change.** To fully reverse product B, post
      a negative payment that zeroes B's balance, inside B's window.


      ```json Full refund of the new product

      {
        "purchase": null,
        "payment": {
          "object": "external_subscription_payment",
          "source_subscription_identifier": "paddle_sub_id5678",
          "payment_identifier": "payment_refund_pro",
          "processed_at": "2023-05-16T00:00:00",
          "amount_in_local_currency": {
            "gross": -19.99,
            "currency": "USD"
          }
        }
      }

      ```


      **Event:** `CANCELLATION` (customer-support reason), because the full
      refund zeroes the period's balance.


      #### Product-change quick reference


      | Behavior | Period A | Period B | Payment(s) | Event |

      |---|---|---|---|---|

      | Upgrade, prorated | trim to switch time | new term, higher price |
      prorated `gross`, `processed_at` in B | `RENEWAL` |

      | Upgrade, full price | trim to switch time | new term, higher price |
      full `gross` in B | `RENEWAL` |

      | Downgrade, deferred | run to natural end | starts at A's end | full
      `gross` in B | `RENEWAL` |

      | Downgrade, immediate + credit | trim to switch time | new term, lower
      price | negative credit in A + charge in B | `RENEWAL` |

      | Crossgrade, same price | trim to switch time | same price | none / `0` |
      `RENEWAL` |

      | Refund / revert | — | zero out via negative payment | full negative
      `gross` in B | `CANCELLATION` |


      Rules of thumb:


      - Same `source_subscription_identifier` + a new period = `RENEWAL`; a
      different identifier = `INITIAL_PURCHASE`.

      - To end A: just post B (auto-trim) for immediate changes; use an explicit
      period correction when you need a precise, event-silent shorten.

      - Post payments with `processed_at` **inside the target period**; refunds
      are a negative `gross`, and a full refund emits `CANCELLATION`.

      - Proration and refunds live entirely in the amounts you post — the period
      model is always "close A, open B."


      ### 2.5 Edge case: a yearly subscription billed monthly


      Some products grant a long access period (for example, one year) while
      charging in smaller installments (monthly). Model this as **one
      transaction** spanning the full access period, with **multiple payment
      objects** attached. Each payment lands on the single yearly period because
      every `processed_at` falls within the same `[starts_at, ends_at)` window,
      which is exactly the time-based matching rule in action.


      Open the year-long period (first installment attached):


      ```json Yearly access, first monthly charge

      {
        "purchase": {
          "object": "external_subscription",
          "customer_id": "app_user_id12341234",
          "source_subscription_identifier": "paddle_sub_id9999",
          "source_product_identifier": "paddle_product_yearly",
          "updated_at": "2023-01-01T00:00:00",
          "current_period_starts_at": "2023-01-01T00:00:00",
          "current_period_ends_at": "2024-01-01T00:00:00",
          "gives_access": true,
          "status": "active",
          "environment": "production",
          "auto_renewal_status": "will_renew"
        },
        "payment": {
          "object": "external_subscription_payment",
          "source_subscription_identifier": "paddle_sub_id9999",
          "payment_identifier": "yearly_payment_2023_01",
          "processed_at": "2023-01-01T00:00:00",
          "amount_in_local_currency": {
            "gross": 5.00,
            "currency": "USD"
          }
        }
      }

      ```


      Each subsequent monthly installment is a **payment-only** request (no
      `purchase`), because the access period hasn't changed:


      ```json Yearly access, second monthly charge

      {
        "purchase": null,
        "payment": {
          "object": "external_subscription_payment",
          "source_subscription_identifier": "paddle_sub_id9999",
          "payment_identifier": "yearly_payment_2023_02",
          "processed_at": "2023-02-01T00:00:00",
          "amount_in_local_currency": {
            "gross": 5.00,
            "currency": "USD"
          }
        }
      }

      ```


      ### 2.6 Cancellation and expiration


      These are the terminal mutators — both edit the current period rather than
      creating a new one. Cancellation means the customer turned off auto-renew
      but still has access until the period ends. Edit the current period to set
      `auto_renewal_status: will_not_renew` while keeping `gives_access: true`.
      This fires a `CANCELLATION` event.


      ```json Cancellation

      {
        "purchase": {
          "object": "external_subscription",
          "customer_id": "app_user_id12341234",
          "source_subscription_identifier": "paddle_sub_id1234",
          "source_product_identifier": "paddle_product_id1234",
          "updated_at": "2023-06-18T00:00:00",
          "current_period_starts_at": "2023-06-01T00:00:00",
          "current_period_ends_at": "2023-07-01T00:00:00",
          "gives_access": true,
          "status": "active",
          "environment": "production",
          "auto_renewal_status": "will_not_renew"
        },
        "payment": null
      }

      ```


      Expiration is when access actually ends. You must post this explicitly:
      set `gives_access: false` and `status: expired`. This fires an
      `EXPIRATION` event.


      ```json Expiration

      {
        "purchase": {
          "object": "external_subscription",
          "customer_id": "app_user_id12341234",
          "source_subscription_identifier": "paddle_sub_id1234",
          "source_product_identifier": "paddle_product_id1234",
          "updated_at": "2023-07-01T00:00:00",
          "current_period_starts_at": "2023-06-01T00:00:00",
          "current_period_ends_at": "2023-07-01T00:00:00",
          "gives_access": false,
          "status": "expired",
          "environment": "production",
          "auto_renewal_status": "will_not_renew"
        },
        "payment": null
      }

      ```


      > Note: Always include `current_period_starts_at` for the transaction you
      are expiring. Otherwise the system may not find your target transaction.
      And remember: `EXPIRATION` events are not automatic — this request is what
      triggers them.


      > Note: The `current_period_ends_at` on the expiration call also
      determines whether a `SUBSCRIPTION_EXTENDED` event fires. Sending an end
      date **later** than the period's previously recorded end moves the paid
      period end forward and can produce a `SUBSCRIPTION_EXTENDED` event
      alongside the expiration; **keeping the original end date** produces none.
      It is up to you to decide which to model.


      ---


      ## 3. Backend implementation checklist


      Before you ship, make sure your backend does all of the following:


      - **Keep `source_subscription_identifier` stable** across the entire life
      of a subscription. Every renewal, status change, and payment must reuse
      it.

      - **Generate new periods for generators, edit periods for mutators.** New
      boundaries create a transaction; re-posting boundaries that resolve to an
      existing period (matched by `current_period_ends_at`) edits it.

      - **Keep the unchanged boundary identical (to the millisecond)** when
      editing, or you'll create a separate overlapping transaction instead of
      updating one.

      - **Order updates with `updated_at`.** Always send a strictly-later
      `updated_at` for subscription edits (exactly-equal for one-time
      purchases). Stale updates are ignored.

      - **Use stable, unique `payment_identifier`s** and treat requests as
      idempotent so retries are safe. Give refunds their own identifiers with
      negative amounts.

      - **Let `processed_at` do the matching.** Don't assume bundling a payment
      with a purchase links them, only the timestamp matters.

      - **Trigger expirations yourself** by posting `status: expired` /
      `gives_access: false`.

      - **Review the constraints.** Read [Backfill Logic](#tag/Backfill-Logic)
      for out-of-order handling and [Beta Limitations](#tag/Beta-Limitations)
      for what is not yet supported.
  - name: Backfill Logic
    x-traitTag: true
    description: >
      ## Backfill new transaction in the subscription


      If subscription statuses arrive out of order (eg. RevenueCat receives a
      previous subscription period before a subsequent subscription period),
      RevenueCat needs to make a decision about how the history of the
      subscription looked like. This section describes various scenarios that
      can occur.


      ### Context


      We know that the new transaction ends before the latest old one.


      ### Overall Scenario


      The transaction can fit everywhere between or overlap the existing
      transactions.  The new transaction can be in any of the following
      positions.


      ![](/docs/images/external-purchases-api/36e8e3a-Screenshot_2023-08-08_at_11.33.29.png)



      We need to find the previous and next overlapping transactions if any.


      #### Scenario 1


      Both previous and next overlapping old transactions exist. As a result, we
      shrink the previous old transaction to end right before the new one.
      Similarly, we shrink the new transaction to end right before the next old
      one.


      ![](/docs/images/external-purchases-api/ab6705e-Screenshot_2023-08-08_at_11.38.02.png)



      #### Scenario 2


      Only the previous overlapping old transaction exist. As a result, we
      shrink the prev transaction to end right before the new one. The new
      transaction is not changed.


      ![](/docs/images/external-purchases-api/acee024-Screenshot_2023-08-08_at_11.39.12.png)



      #### Scenario 3


      Only the next overlapping old transaction exist. As a result, we shrink
      the new transaction to end right before the next old one.


      ![](/docs/images/external-purchases-api/670069c-Screenshot_2023-08-08_at_11.40.13.png)



      #### Scenario 4


      There’s no old transaction overlapping with the new one. As a result, we
      just store the new transaction as is.


      ![](/docs/images/external-purchases-api/9e1c7f6-Screenshot_2023-08-08_at_12.56.05.png)



      #### Scenario 5


      The new transaction overlaps completely a old one (prev). As a result, we
      send ERROR, the new transaction is skipped.


      ![](/docs/images/external-purchases-api/f3b21e7-Screenshot_2023-08-08_at_12.56.52.png)



      ### Move back the latest_expiring_transaction start date if needed


      ### Context


      We know that the new transaction ends after the latest old one.


      #### Scenario 1


      Old transaction starts before and ends during new one. As a result, we
      shrink the old transaction to end right before the new one


      ![](/docs/images/external-purchases-api/0839207-Screenshot_2023-08-08_at_13.00.01.png)



      #### Scenario 2


      Old transaction starts and ends during new one. As a result, we send
      ERROR, the new transaction is skipped.


      ![](/docs/images/external-purchases-api/88623c8-Screenshot_2023-08-08_at_13.01.06.png)



      #### Scenario 3


      Old transaction starts and ends before the  new one. As a result, the new
      transaction is saved, the old one is not modified.


      ![](/docs/images/external-purchases-api/7d07cf7-Screenshot_2023-08-08_at_13.02.19.png)
  - name: Beta Limitations
    x-traitTag: true
    description: >
      The following limitations apply during Beta period:


      1. For `purchase` JSON with `external_subscription` object:
          1. You cannot change the `customer_id` field directly in the External Purchases payload. To move purchases between App User IDs, use the Developer API v2 [Transfer endpoint](/docs/api-v2#tag/Customer/operation/transfer-customer-data) instead.
          2. We don't generate a new event in a scenario, when `current_period_ends_at` was in the future, and it's changed to a different future date.
          3. We support the following options for the `auto_renew_status` field:  `will_renew`, `will_not_renew`, `unknown` value. We omit the `will_change_product` for now, meaning that we don't support Upgrades, Downgrade, Crossgrades and resulting event (`PRODUCT_CHANGE` event).
          4. Family shared subscriptions are not supported (but you can still create shared subscriptions as regular subscriptions).
      2. In a scenario when payments are posted after a subscription or purchase
      was created we don't generate Events (since the revenue information was
      already sent with the `INITIAL_PURCHASE` / `RENEWAL` event)

      3. Receipt validation:
          1. Currently, we do not validate receipts or data posted to RevenueCat via the External purchases API. Customers must ensure that data posted to the endpoint is already validated, and they need to ensure that the External API Key is not getting exposed to the public.
      4. Refunds:
          1. In the scenario when the third party payment service provider retains commissions in case of a refund, Revenuecat does not track negative revenues. Meaning, that in such cases, you will see data discrepancies with the equivalent of the commission retained by the payment service provider.
      5. Entitlement identifier:
          1. We support only new entitlement identifiers ([not the legacy version](/docs/integrations/webhooks/event-types-and-fields#subscription-lifecycle-events-fields)).
      6. [Backfills](#tag/Backfill-Logic) that modify `current_period_ends_at`
      date, do not alter existing Events, which means that the Customer history
      won't reflect accurately the most up to date `expiration_at_ms`.

      7. If a different `source_subscription_identifier` gets provided, doesn’t
      matter if it’s the same product, we can end up with concurrent
      transactions

      8. `EXPIRATION` Events are not fired automatically. The developer needs to
      explicitly send a request to the External Purchases API endpoint when
      `status` changed to `expired`.
  - name: External Purchases
    description: Operations to track external subscriptions and purchases.
paths:
  /v1/receipts/external:
    post:
      tags:
        - External Purchases
      summary: Tracks the status of external purchases
      operationId: track-external-purchases
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ExternalPurchaseRequest'
        required: true
      responses:
        '200':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ExternalPurchaseResponse'
        '400':
          description: Bad Request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BadRequestError'
        '409':
          description: Conflict
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ConflictError'
        '429':
          description: Too Many Requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ExceededQuotaError'
      security:
        - api_key: []
components:
  schemas:
    AmountInLocalCurrency:
      properties:
        currency:
          type: string
          title: Currency
          description: ISO 4217 currency code
        gross:
          anyOf:
            - type: number
            - type: string
          title: Gross
          description: >-
            The gross amount of the payment. Positive for payments, negative for
            refunds.
        tax:
          anyOf:
            - type: number
            - type: string
          title: Tax
          description: >-
            The amount of tax taken out of the gross payment. Positive for
            payments, negative for refunds. Defaults to 0.
        commission:
          anyOf:
            - type: number
            - type: string
          title: Commission
          description: >-
            The amount of fees and commissions taken out of the gross payment.
            Positive for payments, negative for refunds. Defaults to 0.
      additionalProperties: false
      type: object
      required:
        - currency
        - gross
      title: AmountInLocalCurrency
    AmountInUSD:
      properties:
        gross:
          anyOf:
            - type: number
            - type: string
          title: Gross
          description: >-
            The gross amount of the payment. Positive for payments, negative for
            refunds.
        tax:
          anyOf:
            - type: number
            - type: string
          title: Tax
          description: >-
            The amount of tax taken out of the gross payment. Positive for
            payments, negative for refunds. Defaults to 0.
        commission:
          anyOf:
            - type: number
            - type: string
          title: Commission
          description: >-
            The amount of fees and commissions taken out of the gross payment.
            Positive for payments, negative for refunds. Defaults to 0.
      additionalProperties: false
      type: object
      required:
        - gross
      title: AmountInUSD
    BadRequestError:
      properties:
        code:
          type: integer
          title: Code
        message:
          type: string
          title: Message
      type: object
      required:
        - code
        - message
      title: BadRequestError
      examples:
        - code: 7000
          message: Invalid Platform.
        - code: 7226
          message: Some parameters are incorrect.
    ConflictError:
      properties:
        code:
          type: integer
          title: Code
        message:
          type: string
          title: Message
      type: object
      required:
        - code
        - message
      title: ConflictError
      examples:
        - code: 7752
          message: Different transactions cannot overlap.
    ExceededQuotaError:
      properties:
        code:
          type: integer
          title: Code
        message:
          type: string
          title: Message
      type: object
      required:
        - code
        - message
      title: ExceededQuotaError
      description: |2-

            The client exceeded the quota for their API KEY.
            
      examples:
        - code: 7000
          message: Invalid Platform.
        - code: 7226
          message: Some parameters are incorrect.
    ExternalPurchase:
      properties:
        object:
          const: external_purchase
          title: Object
          description: The type of the object
        customer_id:
          type: string
          title: Customer Id
          description: >-
            The `app_user_id` of the customer that this subscription should be
            associated with.
        source_purchase_identifier:
          type: string
          title: Source Purchase Identifier
          description: The purchase identifier in the external source
        source_product_identifier:
          type: string
          title: Source Product Identifier
          description: The product identifier in the external source
        updated_at:
          type: string
          format: date-time
          title: Updated At
          description: >-
            The date and time as of which this status was up-to-date, in ms
            since epoch. This is used to ensure status updates arriving out of
            order are not overwriting a more recent status.
        status:
          anyOf:
            - type: string
              enum:
                - owned
                - refunded
          title: Status
          description: >-
            The status of the external subscription. If not present or `null`,
            will default to `unknown`.
          default: owned
        environment:
          anyOf:
            - type: string
              enum:
                - sandbox
                - production
          title: Environment
          description: >-
            The environment in which this subscription was created. If not
            present or `null`, will default to `production`.
          default: production
        metadata:
          type: object
          title: Metadata
          description: >-
            Metadata about the purchase. Content must be key-value pairs with
            string, number, boolean, or null values.
      additionalProperties: false
      type: object
      required:
        - object
        - customer_id
        - source_purchase_identifier
        - source_product_identifier
        - updated_at
      title: ExternalPurchase
    ExternalPurchasePayment:
      properties:
        object:
          const: external_purchase_payment
          title: Object
          description: The type of the object
        source_purchase_identifier:
          type: string
          title: Source Purchase Identifier
          description: The purchase identifier in the external source
        payment_identifier:
          type: string
          title: Payment Identifier
          description: >-
            A unique identifier to identify this payment. This is used to ensure
            that payments aren't tracked multiple times in case they get posted
            multiple times. You do not have to deduplicate payments. Refunds
            should have distinct identifiers from their original payment. If the
            source doesn't have a unique identifier for a payment, it is
            probably possible to create one from a subscription identifier plus
            subscription period identifier, or subscription identifier plus
            timestamp of the payment.
        processed_at:
          anyOf:
            - type: string
              format: date-time
          title: Processed At
          description: The date and time the payment was processed.
        country:
          anyOf:
            - type: string
          title: Country
          description: The billing country, in ISO3166.1 format.
        amount_in_local_currency:
          allOf:
            - $ref: '#/components/schemas/AmountInLocalCurrency'
          description: >-
            Amount of the payment or refund in the currency it was charged in.
            Will be converted to USD by RevenueCat unless the field
            `amount_in_usd` is present.
        amount_in_usd:
          anyOf:
            - $ref: '#/components/schemas/AmountInUSD'
          description: >-
            Amount of the payment or refund in USD, if available from the
            transaction source.
        active_offer_type:
          anyOf:
            - type: string
              enum:
                - introductory_offer
                - offer_code
                - win_back_offer
                - promotional_offer
                - unspecified_offer
          title: Active Offer Type
          description: The type of offer that was active when the payment was processed.
        active_offer:
          type: string
          title: Active Offer Identifier
          description: >-
            The identifier of the offer that was active when the payment was
            processed.
      additionalProperties: false
      type: object
      required:
        - object
        - source_purchase_identifier
        - payment_identifier
        - amount_in_local_currency
      title: ExternalPurchasePayment
    ExternalPurchaseRequest:
      properties:
        purchase:
          anyOf:
            - $ref: '#/components/schemas/ExternalPurchase'
            - $ref: '#/components/schemas/ExternalSubscription'
          title: Purchase
          description: The current status of one external purchase to track
        payment:
          anyOf:
            - $ref: '#/components/schemas/ExternalSubscriptionPayment'
            - $ref: '#/components/schemas/ExternalPurchasePayment'
          title: Payment
          description: The current status of one external payment to track
      additionalProperties: false
      type: object
      title: ExternalPurchaseRequest
    ExternalPurchaseResponse:
      properties:
        purchase:
          anyOf:
            - type: string
          title: Purchase
          description: The id of the created purchase/subscription. If any was created.
        payment:
          anyOf:
            - type: string
          title: Payment
          description: The id of the created payment. If any was created.
      type: object
      title: ExternalPurchaseResponse
    ExternalSubscription:
      properties:
        object:
          const: external_subscription
          title: Object
          description: The type of the object
        customer_id:
          type: string
          title: Customer Id
          description: >-
            The `app_user_id` of the customer that this subscription should be
            associated with.
        source_subscription_identifier:
          type: string
          title: Source Subscription Identifier
          description: >-
            Identifier of the subscription in the source system. This will be
            used to match subscription status updates. Renewals of the same
            subscription should have the same `source_subscription_identifier`.
        source_product_identifier:
          type: string
          title: Source Product Identifier
          description: The product identifier in the external source
        updated_at:
          type: string
          format: date-time
          title: Updated At
          description: >-
            The date and time as of which this status was up-to-date, in ms
            since epoch. This is used to ensure status updates arriving out of
            order are not overwriting a more recent status.
        current_period_starts_at:
          anyOf:
            - type: string
              format: date-time
          title: Current Period Starts At
          description: >-
            The date and time at which the current period has begun, in ms since
            epoch. If this field is `null` or not present, it is assumed to be
            equal to `status_as_of`, unless this is an update to a previously
            posted subscription period with the same end date.
        current_period_ends_at:
          type: string
          format: date-time
          title: Current Period Ends At
          description: >-
            The date and time at which the current period ends, in ms since
            epoch. If no update is received when the current period ends, the
            subscription is assumed to have expired.
        gives_access:
          type: boolean
          title: Gives Access
          description: >-
            Whether or not the customer should currently have access to any
            entitlements granted by this subscription.
        status:
          anyOf:
            - type: string
              enum:
                - trialing
                - active
                - expired
                - in_grace_period
                - in_billing_retry
                - paused
                - unknown
          title: Status
          description: >-
            The status of the external subscription. If not present or `null`,
            will default to `unknown`.
          default: unknown
        environment:
          anyOf:
            - type: string
              enum:
                - sandbox
                - production
          title: Environment
          description: >-
            The environment in which this subscription was created. If not
            present or `null`, will default to `production`.
          default: production
        auto_renewal_status:
          anyOf:
            - type: string
              enum:
                - will_renew
                - will_not_renew
                - will_change_product
                - will_pause
                - requires_price_increase_consent
                - has_already_renewed
                - unknown
          title: Auto Renewal Status
          description: >-
            Whether or not the subscription is currently set to auto-renew at
            the end of the current subscription period. If not present or
            `null`, will default to `unknown`.
          default: unknown
        metadata:
          type: object
          title: Metadata
          description: >-
            Metadata about the subscription. Content must be key-value pairs
            with string, number, boolean, or null values.
      additionalProperties: false
      type: object
      required:
        - object
        - customer_id
        - source_subscription_identifier
        - source_product_identifier
        - updated_at
        - current_period_ends_at
        - gives_access
      title: ExternalSubscription
    ExternalSubscriptionPayment:
      properties:
        object:
          const: external_subscription_payment
          title: Object
          description: The type of the object
        source_subscription_identifier:
          type: string
          title: Source Subscription Identifier
          description: >-
            Identifier of the subscription in the source system. This will be
            used to match the payment to the subscription.
        payment_identifier:
          type: string
          title: Payment Identifier
          description: >-
            A unique identifier to identify this payment. This is used to ensure
            that payments aren't tracked multiple times in case they get posted
            multiple times. You do not have to deduplicate payments. Refunds
            should have distinct identifiers from their original payment. If the
            source doesn't have a unique identifier for a payment, it is
            probably possible to create one from a subscription identifier plus
            subscription period identifier, or subscription identifier plus
            timestamp of the payment.
        processed_at:
          anyOf:
            - type: string
              format: date-time
          title: Processed At
          description: The date and time the payment was processed.
        country:
          anyOf:
            - type: string
          title: Country
          description: The billing country, in ISO3166.1 format.
        amount_in_local_currency:
          allOf:
            - $ref: '#/components/schemas/AmountInLocalCurrency'
          description: >-
            Amount of the payment or refund in the currency it was charged in.
            Will be converted to USD by RevenueCat unless the field
            `amount_in_usd` is present.
        amount_in_usd:
          anyOf:
            - $ref: '#/components/schemas/AmountInUSD'
          description: >-
            Amount of the payment or refund in USD, if available from the
            transaction source.
        active_offer_type:
          anyOf:
            - type: string
              enum:
                - free_trial
                - introductory_offer
                - offer_code
                - win_back_offer
                - promotional_offer
                - unspecified_offer
          title: Active Offer Type
          description: The type of offer that was active when the payment was processed.
        active_offer:
          type: string
          title: Active Offer Identifier
          description: >-
            The identifier of the offer that was active when the payment was
            processed.
      additionalProperties: false
      type: object
      required:
        - object
        - source_subscription_identifier
        - payment_identifier
      title: ExternalSubscriptionPayment
    HTTPValidationError:
      properties:
        detail:
          items:
            $ref: '#/components/schemas/ValidationError'
          type: array
          title: Detail
      type: object
      title: HTTPValidationError
    ValidationError:
      properties:
        loc:
          items:
            anyOf:
              - type: string
              - type: integer
          type: array
          title: Location
        msg:
          type: string
          title: Message
        type:
          type: string
          title: Error Type
      type: object
      required:
        - loc
        - msg
        - type
      title: ValidationError
  securitySchemes:
    api_key:
      type: http
      description: The External Purchases API Key
      scheme: bearer
