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)
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
}
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);
}
}
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]);
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)