fashn-logo

FASHNAI

Back to Blog

How We Built Cross-Platform Subscriptions for Our iOS App

In this article, we break down how we added subscriptions to our iOS app. We walk through the goals, the payment provider webhook architecture, and the implementation decisions that helped us handle cross-platform subscriptions, credits, and tricky subscription edge cases.

Dinesh SurapurajuAuthor
May 21, 2026
How We Built Cross-Platform Subscriptions for Our iOS App cover image

Introduction

When we started building subscriptions for our iOS app, the app was not meant to be a separate product. It was an extension of our existing platform.

Our existing platform already had a web app, a Next.js backend server (backend), and a Postgres database where we keep account, subscription, billing cycle, and credit state. On the web app, subscriptions are handled through Stripe. The iOS app had to plug into that system instead of creating a second source of truth.

That meant subscriptions had to work across web and mobile. A user with an existing web subscription should be able to open the iOS app and keep using the product. A user who subscribed on iOS should have the same access on the web app. The subscription state, billing cycle, and credit behavior all had to stay in sync across both platforms.

Adding the paywall UI sounded like a weekend project. It was not. The paywall UI itself was the easy part. The hard part was everything behind it: connecting mobile purchases to existing FASHN accounts, keeping billing state consistent with the web app, and making sure subscription events updated credits at the right time.

In this post, we walk through the architecture, implementation decisions, and edge cases that shaped the final system, including why we removed annual plans from mobile entirely to avoid a class of crossgrade problems we did not want to deal with.

Goals

Here's a list of what we needed this system to support:

  • Allow users to purchase a subscription from the mobile app using Apple IAP
  • Let users access the mobile app with their web subscription and vice versa
  • Sync subscription data in our backend
  • Listen to subscription events to offer, revoke and update credits

Architecture

We use RevenueCat to manage subscriptions and RevenueCat webhooks to keep our backend in sync.

We chose RevenueCat because we did not want to build and maintain the Apple subscription plumbing ourselves, especially knowing that we plan to roll out an Android app in the future. It gives us one place to configure products and offerings, handles receipt validation, exposes subscription state through an API, and sends webhooks for lifecycle events like purchases, renewals, cancellations, expirations, and product changes. It also gives us room to use built-in paywalls and A/B testing later if we decide to move more of the paywall experience into RevenueCat. That means we do not have to model every difference in subscription behavior across iOS and Android ourselves, and can focus instead on how those events should affect FASHN accounts, credits, and cross-platform access.

Whenever we receive a webhook from RevenueCat, we consume that event and create an async task using Vercel Workflows, which is our async task runner.

Inside that task, we inspect the event type and process it accordingly.

For every event, we start with the same basic flow:

  1. Sync the subscription data in our database
  2. Decide whether the event should change the user's credits

This split made the system much easier to reason about. All events are used to sync subscription data, but only a few relevant events are used to work on credits. Some events only update billing or access state, while others grant, reset, or revoke credits.

This architecture also gave us one backend controlled system that could support both iOS and web instead of treating them as two separate subscription products.

Implementation

Now that the high-level flow is in place, the next step is explaining how each goal was achieved in practice.

Setting up products in Apple and RevenueCat

Before any code, the subscription products need to be created in both Apple and RevenueCat.

In App Store Connect, subscriptions are organized into subscription groups. All plans that a user can upgrade or downgrade between must live in the same group, because Apple uses the group structure to determine what counts as an upgrade, downgrade, or crossgrade. Within a group, each subscription is assigned a level. The level controls how Apple ranks the plans relative to each other.

We have one subscription group with three plans:

LevelNameDuration
1AgencyMonthly
2ProMonthly
3BasicMonthly

Level 1 is the highest tier. A user moving from Basic to Pro is an upgrade. A user moving from Agency to Basic is a downgrade.

In RevenueCat, we create products that map to the Apple product IDs. These products are then grouped into an offering, which is what the app fetches to display the paywall. We do not use RevenueCat's built-in paywall UI. Instead, we fetch the offering and render a custom paywall using a manual implementation.

Cross-platform subscriptions

One of the main goals was allowing users to move between iOS and web without losing access to their subscription.

To make this work, we did not treat the iOS app as a standalone subscription environment. Instead, we kept the subscription state in our backend and synced it using RevenueCat events.

That meant a user with a web subscription could log into the iOS app and continue using it, and a user who subscribed on iOS could use the same subscription on the web app.

The important part here was that our backend became the place where we determine the latest subscription state. The iOS app can read subscription state from the RevenueCat SDK APIs, but that client-side state is not the source of truth for our product. Our backend is what both web and mobile rely on for access and credit decisions.

Syncing subscription data

Another goal was to keep subscription data in sync in our backend.

Subscriptions are not useful to us if they only exist inside Apple or RevenueCat. We need that data in our own database for a few concrete reasons:

  • We track the billing cycle so we know when to reset credits
  • We track subscription status to unlock paid features for active subscribers
  • We record cancellations so we can show users the date their access ends
  • We keep this state in our backend so both web and mobile can read from the same source

Without this, we would have no reliable way to communicate subscription state to users, support access across platforms, or gate features.

We keep this data updated by listening to RevenueCat webhooks. That is why every RevenueCat event goes through the same sync flow. Whether it is an initial purchase, renewal, cancellation, expiration or product change, we first update the subscription state in our database. After that, only the events that affect credits continue into the credit-handling flow.

This gave us a single source of truth that both platforms could use.

Acting on subscription events

Another goal was listening to subscription events not just for syncing, but also to decide what should happen to credits.

In our app, subscriptions are tied to credits. So a subscription event is not just a billing event. It is also a product event.

For example:

  • On purchase, we offer credits for the subscribed plan
  • On renewal, we reset credits for the new billing cycle
  • On cancellation, we update the subscription state
  • On expiration, we revoke the relevant credits

This was one of the main reasons we needed reliable webhook handling. We were not just mirroring billing state. We were acting on it.

We also make webhook processing idempotent so retries do not grant or reset credits twice.

Initial purchases, renewals and expiration

Initial purchase events are straightforward but important.

When a user purchases a plan successfully, we update the subscription data in our database and then grant credits based on the purchased plan.

Renewals were especially important because our credits reset with the billing cycle. So when a renewal happens, we do not increment credits from the previous month. We refresh the credit balance for the new billing cycle.

For example, if a user is on Basic and had 100 credits left from the previous cycle, we do not add another 200 and make it 300. We simply refresh the balance and give them a fresh 200 credits for the new cycle.

Cancellation and expiration also needed different handling. When a cancellation event comes in, we update the subscription status in the database, but we do not revoke credits immediately because the subscription is still active until the current billing period ends. When the expiration event arrives, that is when we revoke the relevant credits.

Challenges

There were a few challenges that made this project more complex than a normal subscription integration.

Keeping subscription data in sync

The first challenge was keeping subscription data in sync across Apple, RevenueCat, our backend and our app.

We addressed this by routing every RevenueCat webhook through the same backend workflow and making our database the canonical state used by both web and mobile.

Handling upgrades

Before going into how we handled these, it helps to understand how Apple defines subscription changes.

Upgrade. When a user switches to a higher-tier subscription, the change is immediate. Apple refunds the prorated amount of their original subscription.

Downgrade. When a user switches to a lower-tier subscription, the current subscription continues until the next renewal date and then renews at the lower level and price.

Crossgrade. When a user switches to a subscription at the same level, the timing depends on the billing frequency. If both subscriptions are the same duration, the change is immediate. If the durations are different, the new subscription takes effect at the next renewal date.

Upgrades were a challenge because they are immediate changes.

When a user upgrades, the new subscription starts right away with an updated billing cycle. This means we cannot treat upgrades like renewals or cancellations. They need their own handling because the subscription state changes immediately.

Since Apple refunds the prorated amount of the original subscription, we also have to prorate the credits we offer. A user who upgrades mid-cycle should not receive the full credit allocation for the new plan. They should only receive credits proportional to the time remaining in the billing cycle.

// Step 1: Calculate the refund Apple will give for the current plan
current_plan_refund = current_plan_price × (days_remaining / days_in_cycle)

// Step 2: Calculate the net amount the user is paying for the new plan
net_payment = new_plan_price - current_plan_refund

// Step 3: Calculate the proportion of the new plan they're paying for
payment_ratio = net_payment / new_plan_price

// Step 4: Award credits proportionally
new_plan_credits = new_plan_total_credits × payment_ratio

This would have been simpler if Apple offered an option to turn off proration on upgrades. In that case, we could have just offered the full credit allocation for the new plan and carried over any remaining credits from the old plan. But since the effective payment is prorated, we have to prorate the credits to match.

Any remaining credits from the old plan are carried forward as top-up credits. That way, the user's unused credits are not lost, while the new subscription credits still reflect the prorated amount they paid for the upgraded plan.

For upgrades, RevenueCat sends both a PRODUCT_CHANGE event and a RENEWAL event, since the new billing cycle starts immediately.

We handled this by treating product changes as their own event path in the workflow instead of trying to force them into the renewal logic.

Apple account vs app account

This is only a problem if your app requires users to have an account. If it does not, subscriptions are simply tied to the Apple account and there is no mismatch to worry about.

Since our app requires an account, we ran into this. Subscriptions on iOS are tied to the Apple account, not to the account the user logs into inside our app.

A user can have one phone but multiple accounts with us. For example, they may have one account created with Gmail and another created with GitHub.

That means a user could purchase a subscription while using one account, then later log into the app with a different account on the same phone. Even though the subscription exists on their Apple account, it does not automatically belong to every account they have with us.

We had to handle this case carefully. When a user tries to subscribe while logged into a different app account, RevenueCat returns a RECEIPT_ALREADY_IN_USE_ERROR. We catch that error, detect that a subscription already exists but is linked to another account, and show that to the user instead of creating a broken state.

Renewal timing

Syncing renewals was also more challenging than expected.

We receive a RENEWAL event from RevenueCat, but the subscription data in RevenueCat is not always updated at that exact moment. RevenueCat says this happens because Apple can send the renewal event before the actual renewal datetime.

So when we receive the RENEWAL event, we first fetch the latest subscription state from the RevenueCat API and compare it against what we have in our database. If the current_period_starts_at and current_period_ends_at values are the same as what we already have, it means RevenueCat has not updated yet.

If the data is updated, we process the renewal normally.

If it is not updated yet, we schedule a task to run just after the current_period_ends_at timestamp. That gives RevenueCat enough time to reflect the renewed subscription, and we fetch the updated state then.

Offering both monthly and annual plans

Another source of complexity was offering both monthly and annual plans on mobile.

Initially, we had annual plans:

  1. Agency (Monthly/Annual)
  2. Pro (Monthly/Annual)
  3. Basic (Monthly/Annual)

Agency is the highest tier and Basic is the lowest.

Same-tier changes were not the main problem. A move like Basic monthly to Basic annual is a crossgrade, but in our setup those crossgrades always changed billing duration, so they would take effect at the next renewal date.

The harder problem was upgrades that also changed billing frequency. For example, moving from Basic annual to Pro monthly is still an upgrade, but it also changes the subscription duration. That combination made timing and credit proration harder to reason about and easier to get wrong.

Annual plans also added another backend path for credit resets. For monthly plans, the credit reset can be tied directly to the RENEWAL webhook event. For annual plans, the user only renews once a year, but we would still need to reset subscription credits every month according to that user's billing cycle. That is doable with a cron job or workflow, but we did not want to add that extra backend scheduling logic in the first version.

At first, we thought about simply removing the annual options from our paywall. But that would not be enough, because users can still manage subscriptions directly through Apple Settings and trigger those changes there.

To avoid this complexity, we decided to remove annual plans from the mobile subscription group altogether.

After that, the subscription changes we needed to focus on were much simpler because there were no billing frequency changes anymore:

  1. Upgrades
  2. Downgrades

Summary

Overall, the project was less about adding a paywall and more about building a reliable subscription state machine around Apple, RevenueCat and our own backend.

The hardest parts were keeping subscription data in sync, handling the mismatch between Apple accounts and our app accounts, processing renewals only when RevenueCat data was actually ready, and avoiding the complexity of crossgrades and mixed frequency upgrades.

If you are building subscriptions for a cross-platform product, we strongly recommend deciding early how subscription events will map into your own backend state and how those events should affect the rest of your product, especially if subscriptions also control credits or other consumable limits. If that is the case, it is also worth considering offering only monthly plans. Mixing billing frequencies introduces crossgrade scenarios and complicates upgrade proration, because a user changing both their plan level and billing frequency at the same time makes credit calculations significantly harder to reason about and get right.

Resources

FASHN Blog | How We Built Cross-Platform Subscriptions for Our iOS App