DEV Community

Cover image for Wiring Up RevenueCat in a Kotlin Multiplatform App: Entitlements, Webhooks, and Why the Client Should Never Be Trusted
WolfOf420Stret
WolfOf420Stret

Posted on

Wiring Up RevenueCat in a Kotlin Multiplatform App: Entitlements, Webhooks, and Why the Client Should Never Be Trusted

I've been building Affirmi, a mood-based affirmation app.

The idea is pretty simple. Instead of throwing a random motivational quote at you, Affirmi tries to give you something that actually fits how you're feeling.

Getting that part right is one problem.

Getting people to pay for it is another.

This post is about the second problem.

More specifically, I'll walk through how I wired subscriptions into Affirmi across Android, iOS, Desktop, and Web using RevenueCat, and the mistake I almost made before I really thought through what an entitlement should mean when you have multiple clients talking to the same backend.

Why not just use StoreKit and Play Billing directly?

I could have.

Apple has StoreKit. Google has Google Play Billing. Both are perfectly capable of handling subscriptions.

The problem starts when your application isn't just one mobile app.

Affirmi is a Kotlin Multiplatform project. I have Android and iOS clients, but I also have Desktop and Web targets.

I didn't want to build receipt validation against Apple's systems, then build another implementation against Google's, and eventually figure out how to make the same subscription state available to Desktop and Web.

I also wanted my backend to have a reliable answer to a very basic question:

Is this particular user entitled to premium right now?

That's where RevenueCat came in.

RevenueCat gives me one layer over Apple and Google billing, along with CustomerInfo, which gives the client a consistent view of a customer's purchases and entitlements.

That removes a lot of platform-specific billing code.

It does not, however, remove the need to think carefully about trust.

I learned that part the hard way.

Getting purchases-kmp into a KMP project

RevenueCat's Kotlin Multiplatform SDK lives at github.com/RevenueCat/purchases-kmp.

It's actually an interesting project in its own right. It started life as a community project called KobanKat before RevenueCat adopted it and turned it into the official KMP SDK.

Adding it is fairly straightforward.

For example, my version catalog contains:

# libs.versions.toml

[versions]
purchases-kmp = "2.10.2+17.55.1"

[libraries]
purchases-core = {
    module = "com.revenuecat.purchases:purchases-kmp-core",
    version.ref = "purchases-kmp"
}
Enter fullscreen mode Exit fullscreen mode

The official KMP installation guide shows the dependency being added to commonMain.

If your application only targets Android and iOS, that makes sense.

Mine doesn't.

The commonMain problem

purchases-kmp-core currently targets Android and iOS/Native. It doesn't provide the JVM or JS/Wasm artifacts needed by my Desktop and Web targets.

So putting it directly in commonMain creates a problem.

The mobile implementations instead live in their respective source sets:

kotlin {
    sourceSets {
        androidMain.dependencies {
            implementation(libs.purchases.core)
        }

        iosMain.dependencies {
            implementation(libs.purchases.core)
        }

        // Desktop and Web use their own implementations of the
        // subscription repository.
    }
}
Enter fullscreen mode Exit fullscreen mode

The shared code depends on my own subscription repository abstraction.

Android and iOS back that abstraction with RevenueCat.

Desktop and Web get their subscription state from the Affirmi backend.

That separation ended up being much cleaner than trying to force RevenueCat into every target.

Two KMP details that caught me

There were also two details in the installation process that I had to dig into.

ExperimentalForeignApi

On iOS, the SDK uses Kotlin's native interop to communicate with StoreKit.

That means you need to opt into ExperimentalForeignApi.

Without it, the iOS source set won't compile.

It's not complicated. It's just one of those things that is easy to miss until the compiler tells you about it.

Static iOS frameworks

Depending on your project configuration, you'll also need your iOS framework to be static.

For example:

listOf(
    iosX64(),
    iosArm64(),
    iosSimulatorArm64()
).forEach { iosTarget ->
    iosTarget.binaries.framework {
        baseName = "affirmi_shared"
        isStatic = true
    }
}
Enter fullscreen mode Exit fullscreen mode

Again, this isn't really a RevenueCat-specific problem.

It's part of the fun of combining native libraries with KMP.

You don't always discover these things by reading documentation. Sometimes the compiler is your documentation.

Wrapper, not typealias

One thing from RevenueCat's own engineering write-up stuck with me.

When you're building a multiplatform SDK, you have a choice.

You can expose platform-specific types directly, often using typealias, or you can create proper multiplatform wrappers.

The typealias approach is tempting.

It's less code.

It also looks elegant.

The problem is that consumers eventually start depending on the underlying platform type.

Once that happens, you've effectively leaked the platform implementation through your supposedly shared API.

Changing the implementation later becomes much harder.

RevenueCat chose to wrap platform types in its KMP SDK, and I think that's the right decision.

It's also how I try to approach Affirmi's own shared architecture.

Platform SDKs should sit behind boundaries.

That costs some code up front, but it gives you room to change the implementation later.

I've found that to be a pretty good trade-off in KMP projects.

What Affirmi actually asks RevenueCat

Once RevenueCat is configured, the actual question Affirmi asks isn't particularly complicated.

It mostly comes down to:

Does this user currently have the premium entitlement?

RevenueCat exposes that through CustomerInfo.

For example:

val customerInfo = Purchases.sharedInstance.awaitCustomerInfo()

val isPremium =
    customerInfo.entitlements.active["premium"]?.isActive == true
Enter fullscreen mode Exit fullscreen mode

That's basically it.

I intentionally chose a single entitlement called premium.

I could have created separate entitlements for:

  • Voice generation
  • Unlimited affirmations
  • Advanced insights
  • Voice cloning

But that would have made the billing model more complicated than the product actually is.

Affirmi has a premium tier.

The billing system needs to know whether someone has access to that tier.

The backend can then decide what premium users are allowed to do.

That separation keeps things much simpler.

The mistake I almost shipped

This is probably the most important part of the whole implementation.

My first instinct was to trust the entitlement returned by RevenueCat on the client.

Something like:

val isPremium =
    customerInfo.entitlements.active["premium"]?.isActive == true
Enter fullscreen mode Exit fullscreen mode

Then I'd use that boolean to decide whether someone could perform premium operations.

Generate a voice affirmation?

Check isPremium.

Clone a voice?

Check isPremium.

Allow unlimited generations?

Check isPremium.

It sounds reasonable at first.

RevenueCat knows whether the user paid, right?

So why not just ask RevenueCat?

There are two problems.

Problem 1: CustomerInfo can be stale

RevenueCat caches CustomerInfo.

That's intentional. You don't want every UI check to require a network request.

But it also means the client can temporarily have information that doesn't reflect the latest state.

A refund, cancellation, renewal, or other change can take some time to make its way through the system and into the client's local state.

That's perfectly acceptable for UI.

If the UI takes a few seconds to update a premium badge, nobody is losing money.

It's a different story when the application is about to spend money on an expensive operation.

Problem 2: the client is not a trusted environment

This one is more fundamental.

The client controls the client.

If my backend receives:

isPremium = true
Enter fullscreen mode Exit fullscreen mode

from an application running on somebody else's device, that value isn't an authorization decision.

A modified application can lie.

A compromised device can lie.

A replayed request can lie.

It doesn't matter whether the original SDK is perfectly secure.

The moment I use a client-controlled value as authorization, I've created a hole.

That led me to a simple rule for Affirmi:

Client-side entitlement state is for UI. Backend entitlement state is for authorization.

The client can use RevenueCat to decide whether to show:

  • A premium badge
  • A paywall
  • Premium UI
  • An "unlimited" label
  • Subscription status

But anything that costs Affirmi money or exposes protected functionality goes through the backend.

So where does the real answer live?

The backend.

RevenueCat sends subscription events to Affirmi through webhooks.

The backend receives those events, verifies them, updates its own entitlement state, and uses that state when processing protected requests.

The flow looks roughly like this:

Client purchases
       |
       v
App Store / Google Play
       |
       v
RevenueCat validates the purchase
       |
       v
RevenueCat webhook
       |
       v
Affirmi backend
       |
       +--> Verify webhook authentication
       |
       +--> Claim event.id
       |
       +--> Reject stale events
       |
       +--> Update entitlement state
       |
       v
Firestore
       |
       v
Premium API requests check backend state
Enter fullscreen mode Exit fullscreen mode

The client's CustomerInfo is still useful.

It's just not the final authority.

Handling RevenueCat webhooks

The webhook itself is fairly straightforward.

You create an endpoint and register it in the RevenueCat dashboard under the webhook integration.

RevenueCat then sends events such as:

  • INITIAL_PURCHASE
  • RENEWAL
  • CANCELLATION
  • and other subscription lifecycle events

The payload contains entitlement_ids, which is particularly useful because RevenueCat has already resolved the product-to-entitlement relationship.

My backend doesn't need to maintain its own table mapping every store product to every entitlement.

There are a few things here that I think are worth paying attention to.

Respond quickly

RevenueCat expects the webhook endpoint to respond successfully.

If your endpoint takes too long or fails, RevenueCat can retry the event.

My current implementation processes the Firestore update synchronously and returns 200 after the write succeeds.

That works fine for the current scale of Affirmi.

At larger scale, I'd rather acknowledge the webhook quickly and put the event onto a queue.

That way a slow database write doesn't turn into a webhook retry.

It's one of those architectural decisions that doesn't need to be over-engineered before you actually need it.

For now, the synchronous implementation is simple and reliable enough.

Idempotency is not optional

This was another part where my first implementation was good enough, but not good enough enough.

The obvious approach is to make the Firestore update idempotent.

If RevenueCat sends the same event twice, writing the same entitlement state twice doesn't really hurt anything.

That works.

But there's a better way.

RevenueCat events have an event.id.

So instead of only making the final database update safe to repeat, Affirmi tracks the event itself.

Each event attempts to claim a distributed lease in a revenueCatEvents collection using its event.id.

If that event has already been processed, the handler stops immediately.

Something like:

event.id
   |
   v
revenueCatEvents
   |
   +--> Already processed?
   |       |
   |       +--> Yes -> duplicate_ignored
   |
   +--> No
           |
           v
       Process event
           |
           v
       Update entitlement
Enter fullscreen mode Exit fullscreen mode

This also gives me a clean audit trail of what the backend has processed.

What about events arriving out of order?

This is another easy one to overlook.

Imagine:

RENEWAL
   |
   v
processed successfully

       ...

CANCELLATION
   |
   v
delayed delivery of an older event
Enter fullscreen mode Exit fullscreen mode

If the backend blindly applies every event it receives, the older cancellation could overwrite the newer subscription state.

RevenueCat doesn't guarantee webhook delivery order.

So Affirmi also compares the incoming event timestamp against the timestamp of the last entitlement update.

If the incoming event is older, it is treated as stale.

That gives me two separate protections:

  1. Duplicate events are ignored using event.id.
  2. Old events can't overwrite newer subscription state.

Those are two different problems, and solving one doesn't solve the other.

Protecting Firestore too

There is another layer that I don't think gets enough attention.

Even if the API is correctly protected, your database shouldn't allow the client to simply change its own subscription state.

Affirmi's Firestore rules prevent authenticated clients from writing subscription-related entitlement fields on their own user document.

Things like:

isPremium
subscription
entitlements
Enter fullscreen mode Exit fullscreen mode

aren't client-writable.

The backend is responsible for changing those values.

So even if someone bypasses the application and talks directly to Firestore, authentication alone isn't enough to grant themselves premium access.

That's the kind of defense-in-depth I want around anything related to billing.

Authenticating the webhook

RevenueCat supports webhook authentication mechanisms including a shared secret and a cryptographic signature.

The shared-secret approach uses an Authorization header.

The signature approach uses:

X-RevenueCat-Webhook-Signature
Enter fullscreen mode Exit fullscreen mode

with a timestamp and HMAC-SHA256 signature.

The signature can also be checked against a timestamp window to protect against replayed requests.

Affirmi verifies the shared secret and separately verifies the HMAC signature.

For the shared secret comparison, I'm using MessageDigest.isEqual rather than a normal string comparison.

The important thing isn't the exact Java method, though.

The important thing is that the backend verifies that the webhook actually came from the expected source before changing subscription state.

If you're building this yourself, don't treat webhook authentication as an optional extra.

This endpoint has the ability to grant or revoke paid access.

Protect it accordingly.

Desktop and Web are a little different

This is where having four KMP targets forced me to think more carefully about the architecture.

Android and iOS have actual app-store purchase flows.

Desktop and Web don't.

There is no StoreKit running inside a browser.

There is no Google Play Billing API running inside a JVM desktop application.

So I didn't try to pretend there was.

Instead, the shared code depends on a subscription repository.

On Android and iOS, that repository can communicate with RevenueCat.

On Desktop and Web, it gets entitlement state from the Affirmi backend after authentication.

If somebody tries to purchase through a target that doesn't support purchasing, the application should be honest about that.

It shouldn't display a fake purchase flow just because the interface happens to be shared.

This is one of the things I like about KMP when it's used properly.

The goal isn't:

"Every platform must behave identically."

The goal is:

"Share the logic that actually benefits from being shared."

The entitlement model can be shared.

The purchasing implementation doesn't have to be.

What I'd do differently if I started again

After going through this implementation, there are a few things I'd recommend to anyone doing something similar.

1. Decide what an entitlement actually means first

Don't start by creating products and randomly attaching feature flags to them.

Define the business concept first.

For Affirmi, that's:

premium
Enter fullscreen mode Exit fullscreen mode

Everything else can build on top of that.

2. Never authorize expensive operations using client state

Use RevenueCat's client SDK for UI.

Don't use:

isPremium == true
Enter fullscreen mode Exit fullscreen mode

as authorization for an expensive backend operation.

The backend should make that decision.

3. Treat webhooks as a core part of the integration

RevenueCat isn't just an SDK you install into your app.

The backend integration matters just as much.

Your client needs to know what's happening.

Your backend needs to know what's happening.

Those are different jobs.

4. Make webhook processing idempotent

Retries aren't exceptional behavior.

They're part of distributed systems.

Track event.id.

Don't just assume that writing the same final value twice is enough.

5. Handle out-of-order events

Duplicate handling and ordering are different problems.

Check both.

An old event shouldn't be able to overwrite a newer subscription state.

6. Lock down the database

Don't let authenticated users write their own entitlement fields.

Authentication tells you who the user is.

It doesn't mean the user should be allowed to grant themselves premium access.

7. Don't force unsupported platforms into the purchase flow

Desktop and Web don't need to pretend they're Android and iOS.

Let each platform do what it can actually do.

Final thoughts

The interesting part of adding RevenueCat to Affirmi wasn't really the RevenueCat API.

The API is fairly small once everything is configured.

The interesting part was realizing that this question:

"Is this user premium?"

actually means two different things.

The first question is:

"What should I show this user?"

The second is:

"What am I willing to let this user do?"

Those sound almost identical.

They're not.

The client can answer the first one.

The backend needs to answer the second one.

That distinction changed how I structured the entire subscription system.

RevenueCat's SDK is great for giving the client a convenient view of subscription state. Webhooks give the backend a way to maintain its own trusted state.

Once I stopped treating those as the same thing, the architecture became much easier to reason about.

And, more importantly, I stopped putting a billing decision in the hands of a boolean that came from someone else's phone.

References

Building Affirmi

If you're interested in what I'm building with Kotlin Multiplatform, you can check out Affirmi.

The idea is simple:

Affirmations should meet you where you are instead of giving everyone the same generic quote.

I'm documenting the engineering side of the project as I build it.

That includes KMP architecture, recommendation systems, offline-first data, AI-generated affirmations, privacy, widgets, subscriptions, and all the other less glamorous engineering work that sits behind a product.

Because getting the feature working is only part of the job.

You also have to make sure the right person gets access, the right person gets charged, and nobody gets premium features just because the client decided to say true.

Top comments (0)