RevenueCat / purchases-flutter

Flutter plugin for in-app purchases and subscriptions. Supports iOS, macOS and Android.
https://www.revenuecat.com/
MIT License
608 stars 169 forks source link

Ensuring webhook is called before client is notified of new entitlement #1094

Open lukehutch opened 5 months ago

lukehutch commented 5 months ago

In my app, when the user purchases a product, the app is notified of the new entitlement, and then separately the server is notified of the new entitlement via the webhook.

However, I need a way to guarantee that the webhook call is completed before the app is notified of the new entitlement. Otherwise, if the app receives the entitlement first, the app may try to call a method on the server that the serve thinks the user should not have access too (because the webhook call hasn't been received yet, so the endpoint is not yet authorized for this user).

Is there some way to guarantee that the webhook call is always completed before the client is notified of the new entitlement? (Or is that already the behavior of RevenueCat?)

Sorry if this is a FAQ, I didn't see anything about this in the docs...

RCGitBot commented 5 months ago

👀 We've just linked this issue to our internal tracker and notified the team. Thank you for reporting, we're checking this out!

HaleyRevcat commented 5 months ago

Hi, this webhook delay could have to do with not having server notifications enabled. Can you make sure that you have these enabled for the platforms you are using? See our docs here for how to do this: https://www.revenuecat.com/docs/platform-resources/server-notifications

lukehutch commented 5 months ago

@HaleyRevcat thanks, I already have server notifications enabled. But I am looking for a guarantee that my app will be notified of the successful purchase only after the webhook has been successfully called. There is nothing in the docs saying that "A purchase request will only return a successful result after the webhook has been called, assuming that a webhook has been configured, and that server side notifications have been enabled".

This may already be true, but I need to know that this is a fundamental rule about how RevenueCat works.

lukehutch commented 4 months ago

Hi, any updates? Does this guarantee hold?

The only alternative I can think of is that upon the client being notified of a successful purchase, the client should call the server and ask the server to pull down the user's purchase information if the webhook has not yet been called, so that the client can know that the server is up to date.

It seem silly to have to do that, but if no guarantee can be made that the webhook has been called before the client is notified, then there is no alternative. If the client tries to call a privileged endpoint immediately after being notified of a successful subscription, and the server has not yet been notified via a webhook, then the endpoint call will fail (in other words, there would be a race condition).

HaleyRevcat commented 4 months ago

Hi, We can't guarantee which one will be delivered first, but can expect to get both around the same time.

Your alternative is the right idea, you don't want to handle both at the same time. You should wait until your server gets the webhook then the server can tell the device that it got the webhook, or vice versa where if you get the entitlement in app without the webhook, then tell your server. I believe the easiest way to go about this would be to wait for the webhook as this is more convenient.

lukehutch commented 4 months ago

@HaleyRevcat That is not convenient at all! In the client (the app), currently I call

await Purchases.purchasePackage(package)

and when that call returns, I check the CustomerInfo to see if the purchase succeeded or not. However, since you said the delivery order can't be guaranteed, I have to ignore the result of this awaited call (because my app server may not have the same updated ground truth about which entitlements are currently granted), then I have to have my app server wait for the webhook call (which there is a non-zero chance will never be received), then my app server has to notify the app through a push notification or websocket message (which is also not strictly guaranteed to be delivered) about the new entitlements, and then my app has to have a separate asynchonous handler receiving messages from the app server about ground truth updates. This makes the CustomerInfo returned from purchasePackage useless.

It also means that I can't update the UI to show the new entitlement info as soon as the purchasePackage call returns -- and the delay before I can update the UI may be arbitrarily long. This is a major problem, because I need to provide users with their paid entitlement functionality the instant that the purchasePackage call indicates a successful purchase, otherwise the app will provide a bad user experience, because users will think their purchase didn't succeed. If I trust the returned CustomerInfo and provide the user with the paid functionality, but they trigger a server call and the server denies the action because it has not yet been notified of the new entitlement, then the paid user experience will break. There is nothing more important to have working 100% of the time in any app that a premium paid feature.

All you would have to do to fix this on your end is await the webhook call successfully returning a 200 response before returning the result from the purchasePackage call. Then apps would know with 100% certainty that the webhook call succeeded before they were returned any successful CustomerInfo -- and the app would know that the CustomerInfo contains exactly the same ground truth about purchases and entitlements that the app server has received via the webhook.

If the webhook call fails, then the CustomerInfo should never be returned to the user -- the purchasePackage call should instead throw an exception indicating that the webhook call failed. Then the app can nudge the server to manually fetch the most recent entitlement ground truth via a call to https://api.revenuecat.com/v1/subscribers/user_$userId (API v1). I have to call that endpoint already even in the case of a successful webhook call, because the docs advise doing this.

Please escalate this, this is a very serious flaw in the design of the RevenueCat package purchasing flow, and it is a major pain to have to code defensively around this problem.

lukehutch commented 4 months ago

So just to clarify, this is the flow I am looking for (assuming a webhook is configured):

  1. App calls await Purchases.purchasePackage(package), which calls out to RevenueCat directly.
  2. RevenueCat calls app server via webhook, and awaits the result.
  3. Only after the webhook call returns a succesful (HTTP 200) result does await Purchases.purchasePackage return the CustomerInfo.

Currently the following flow is the only way of guaranteeing that the app and the app server have the same information on purchases as RevenueCat:

  1. App calls await Purchases.purchasePackage(package).
  2. When that call returns, the returned CustomerInfo is discarded.
  3. App call the app server, and asks the app server to call the subscriptions REST endpoint in the RevenueCat server to get purchase information. The app server stores the purchase information in the user account in the database. The app server then returns the updated purchase information to the app.
  4. When the app server receives a webhook call, the app server ignores the webhook call body, and calls out to the subscriptions REST endpoint. This may happen either before or after the app-triggered call to the same endpoint. (The webhook call is still needed, because non-purchase events may trigger a change in entitlements, e.g. a purchase is refunded. The app server will need to notify the client in these cases.)

This latter flow is much more inefficient -- there are several extra roundtrips, and the subscriptions endpoint has to be hit twice to guarantee that the server and client have the same view of the data (which is extra load on RevenueCat's servers).

await-ing the result of the webhook call before returning anything from purchasePackage, and then offering the requisite guarantee about ordering, is hopefully a very easy fix on your end.

aboedo commented 4 months ago

@lukehutch

I see how this can be awkward, but having us delay the response until the webhook returns a success would introduce a lot of complications that could ultimately lead to a bad user experience.

For one, we don't control servers other than our own, so webhooks might take a long time to get a response, and they might fail if a customer's servers are down or misbehaving, but that doesn't in all cases mean that the user should not get access. If anything, not granting access in this case is fairly rare (although, I can see that it is what you need). Note that a user whose purchase went through but then the webhook failed is still getting charged, and their purchase was registered correctly by our servers.

Much like relying on a push notification can be complicated because delivery isn't guaranteed, webhooks suffer from the same problem since we can only control our own servers.

As for finding ways to have your server provide service only if the user has access, it's still a good idea for your server to check with ours in any case - a malicious user could be replicating network calls from the app to your server in order to get free access, and they could even do this from a non-jailbroken device.

So I'd recommend basically:

Doing this, any time the webhook arrives before success returns, there would be no extra backend calls. And when it does, there would only be one extra call to subscriptions.

Hope this helps!

lukehutch commented 4 months ago

OK, so you basically confirmed that the CustomerInfo received from purchasing a product is more or less useless, except as a trigger for the app to call the app server, so that the server can call the REST endpoint to get a server-trusted view of the ground truth.

However the following suggestion is basically impossible, because there's no way for the app server to know when it receives a webhook request if that request is supposed to be paired with the previous purchase confirmation received by the app, or the next one -- and there is no way to know the time delay or ordering between the app and the app server receiving updated views of the same info:

However, there is also a second race condition here, caused by the relative ordering between the webhook call being received, and the new entitlements/purchases being written to the RevenueCat databases. Can you please confirm whether it is guaranteed that by the time the webhook call is initiated, the updated entitlement/purchase info has been written to the RevenueCat database, so that if my webhook function calls back to the RevenueCat REST endpoint (as your docs suggest, rather than parsing the webhook request itself), the latest purchase will always be included in the result of the REST call?

I am concerned that since you can't offer any guaranteed ordering between the app purchase completing and the webhook call being made, then you also won't be able to offer any guaranteed ordering between the webhook call being made and the RevenueCat database being updated for the REST call to return the latest data. If that is the case, that's an even more serious problem than the first problem!

This would all be a lot simpler if you could guarantee the following ordering:

  1. Customer makes purchase request, which is approved, but purchase request awaits the next two steps before returning.
  2. New purchases are written to the RevenueCat database, as used by REST endpoint calls, and (crucially) the database update is awaited.
  3. Only after the previous step has completed, webhook calls are made, and (crucially) their completion awaited.
  4. Only after the previous step has completed, the purchase request returns a successful result back to the app.

Implementing this is trivial in any async/await-capable programming language. I would hope you would simply make this change, because it would solve every problem I have raised, and it would allow you to offer much stronger guarantees to your users about the validity of each data view and each event.

On Fri, Jun 28, 2024 at 3:45 PM Andy Boedo @.***> wrote:

@lukehutch https://github.com/lukehutch

I see how this can be awkward, but having us delay the response until the webhook returns a success would introduce a lot of complications that could ultimately lead to a bad user experience.

For one, we don't control servers other than our own, so webhooks might take a long time to get a response, and they might fail if a customer's servers are down or misbehaving, but that doesn't in all cases mean that the user should not get access. If anything, not granting access in this case is fairly rare (although, I can see that it is what you need). Note that a user whose purchase went through but then the webhook failed is still getting charged, and their purchase was registered correctly by our servers.

Much like relying on a push notification can be complicated because delivery isn't guaranteed, webhooks suffer from the same problem since we can only control our own servers.

As for finding ways to have your server provide service only if the user has access, it's still a good idea for your server to check with ours in any case - a malicious user could be replicating network calls from the app to your server in order to get free access, and they could even do this from a non-jailbroken device.

So I'd recommend basically:

  • upon a successful purchase, call your backend endpoint, then have your backend use our rest API to ensure that the call is valid
  • you can cache the entitlements for the user so you don't need to perform this check every time
  • you can use the webhook and its contents as way to cache entitlements so if the webhook arrived before your app's request, you don't need to contact our servers

Doing this, any time the webhook arrives before success returns, there would be no extra backend calls. And when it does, there would only be one extra call to subscriptions.

Hope this helps!

— Reply to this email directly, view it on GitHub https://github.com/RevenueCat/purchases-flutter/issues/1094#issuecomment-2197699483, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAGGCKIYCMJG2KYS5IZE3D3ZJXKRNAVCNFSM6AAAAABI4CLSW6VHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZDCOJXGY4TSNBYGM . You are receiving this because you were mentioned.Message ID: @.***>

EArminjon commented 2 months ago

For those who use Firebase claims and specially claims inside firestore security rules : As RevenueCat didn't update claims before responding succeed you can't use your subscription right after gain it. You need to refresh first your Firebase Token. If you force user token to be refreshed once RevenueCat front sdk give you the success callback you can have an issue where the new token still didn't have the claims because RevenueCat didn't yet update it.

This is painful specially to manage subscription cancelling, renewal and real time.

lukehutch commented 2 months ago

This is painful specially to manage subscription cancelling, renewal and real time.

This is by far the biggest problem and liability of using RevenueCat.

Please, RevenueCat developers, reconsider this bugfix request. The change is very simple: just wait to return from any API call until the RevenueCat view of the entitlements and purchases is consistent with the app store view.