DEV Community

Philip Perry
Philip Perry

Posted on • Edited on

Getting In-App Purchases working for an Android App created with React Expo

When I set out to add in-app purchases (IAP) to Interactive Audio Stories, I imagined this would be very simple as I’m not the first to do it. But the reality was a marathon of backend and frontend changes, endless debugging, and a lot of learning. Here’s how it unfolded.

Backstory

While users can read stories for free, all these AI credits cost me money so I decided to add a paid feature which allows users to generate their own interactive stories. They can login with a Google user and we keep track of how many stories they can create with a count field in the Firebase users collection. So initially count is set to zero, but when they make an in-app purchase it adds 1 to the existing value which will allow them to create one story (or more if they made multiple purchases before creating a story).

Choosing a Payment Library

Initially, I went with RevenueCat, but I found their implementation an overkill for a simple app like mine and more geared towards subscriptions. I also couldn’t get it to work as intended. Then I tried Adapty. What I didn’t understand at first it that Adapty does not support consumables. A consumable is a purchase that unlocks content that can be "consumed", e.g. for my app that would be "consuming" the generation of one story. So making an in-app purchase doesn't unlock the feature forever, but just for one time.

Finally, I came across https://github.com/hyochan/expo-iap which does exactly what I need. One needs to implement validation in the backend, but the app needs to make a call to the backend anyways in order to increment the count field.

Backend

Validating Receipts with Google Play

A key part of secure IAP is verifying the purchase server-side. This Go snippet shows how to call the Google Play API to validate a purchase token:

url := fmt.Sprintf("https://androidpublisher.googleapis.com/androidpublisher/v3/applications/%s/purchases/products/%s/tokens/%s",
    req.PackageName, req.ProductId, req.ProductToken)
reqGoogle, err := http.NewRequest("GET", url, nil)
reqGoogle.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := client.Do(reqGoogle)
Enter fullscreen mode Exit fullscreen mode

Annoyingly, the first draft which I generated with LLM passed in the access token as a query string instead of setting an authorization header and it took me a while to realise that. I don't know how vibe coders create these type of apps...

Getting the access token

In order to make the above request for verifying the purchase, one first needs to make a request to get the access token. To access the Google Play API, one needs to load service account credentials which is a json file that one needs to create and download from Google cloud.

func getGoogleAccessToken() (string, error) {
    ctx := context.Background()
    credPath := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")
    if credPath == "" {
        return "", fmt.Errorf("GOOGLE_APPLICATION_CREDENTIALS env var not set")
    }
    log.Printf("[Receipt] Loading Google credentials from: %s", credPath)
    jsonData := readFileOrPanic(credPath)
    // Log client_id from the service account JSON
    var credMeta struct {
        ClientID string `json:"client_id"`
    }
    if err := json.Unmarshal(jsonData, &credMeta); err == nil {
        log.Printf("[Receipt] Service Account client_id: %s", credMeta.ClientID)
    } else {
        log.Printf("[Receipt] Could not parse client_id from service account JSON: %v", err)
    }
    creds, err := google.CredentialsFromJSON(ctx, jsonData, "https://www.googleapis.com/auth/androidpublisher")
    if err != nil {
        return "", err
    }
    log.Printf("[Receipt] Successfully loaded Google credentials")

    token, err := creds.TokenSource.Token()
    if err != nil {
        return "", err
    }
    return token.AccessToken, nil
}
Enter fullscreen mode Exit fullscreen mode

Frontend

This is how we handle the button click handlePurchase. One thing worth noting is that for Android we pass in skus while for iOS it's sku. I decided to pass in both, even though we don't support iOS at the moment...

const handlePurchase = async () => {
    let productId = 'my_product';

    try {

      await requestPurchase({
        request: {
          sku: productId,
          skus: [productId],
        },
        type: 'inapp',
      });
    } catch (error) {
      console.log("products loaded", products),
        console.error('The purchase failed:', error);
    }
  }
Enter fullscreen mode Exit fullscreen mode

This is the part of the frontend code that makes a request to the backend verification:

useEffect(() => {
    if (currentPurchase) {
      const completePurchase = async () => {
        console.log('Current purchase:', currentPurchase);
        try {
          // Parse the purchase receipt
          let verifiedPurchase = JSON.parse(currentPurchase?.transactionReceipt);
          const packageName = "com.artisanphil.myaudiostory";
          const productToken = verifiedPurchase.purchaseToken;
          const productId = verifiedPurchase.productId;

          // Call backend for receipt validation
          const res = await fetch(`${API_BASE}/receipt/validate`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              packageName,
              productToken,
              productId,
              userId: userInfo.id,
            }),
          });
          const data = await res.json();
          if (!res.ok || !data.valid) {
            Alert.alert('Purchase Invalid', 'Your purchase could not be validated. Please contact support.');
            return;
          }

          // Finish the transaction
          await finishTransaction({
            purchase: currentPurchase,
            isConsumable: true, // Set true for consumable products
          });

          // Grant the purchase to user
          console.log('Purchase validated and completed successfully!');
          await refreshUserData(userInfo, "storage", setUserInfo);
        } catch (error) {
          console.error('Failed to complete purchase:', error);
        }
      };

      completePurchase();
    }
  }, [currentPurchase]);
Enter fullscreen mode Exit fullscreen mode

Note that we pass in isConsumable: true when finishing the transaction.

There is a bit more to it, but best to consult the official documentation at https://expo-iap.hyo.dev/docs/api/use-iap/

Conclusion

Programming isn’t always smooth sailing, even for seasoned developers, and that’s part of what makes it interesting. Since I couldn’t find any existing article for this particular use case, I wanted to share my own experience in the hope it saves you a few bumps along the way.

Top comments (0)