Testing
This page tests the whole purchase pipeline on both stores:
| Apple (App Store) | Google Play | |
|---|---|---|
| Mobile | Sandbox Apple Account on a physical device, or TestFlight | License tester with test cards, sideloaded or from a testing track |
| Server | /iap/verify with a real signed transaction, App Store Server Notifications (Sandbox URL) |
/iap/verify with a real purchase token, Real-time Developer Notifications through Pub/Sub |
Work through it in order. Each part builds on the one before, so when something breaks you know which layer broke:
- Prepare both stores and the server configuration.
- Test the server on its own, without a device.
- Test Apple end to end: device, verification, then notifications.
- Test Google end to end: device, verification, then notifications.
- Run the test plan on both platforms before you ship.
The examples use the product ids from the sample app: coins_100 (consumable), remove_ads (non-consumable) and premium_monthly (auto-renewable subscription). They also assume a server set up like the sample server, with the webhooks at /iap and verification at /iap/verify. Replace com.mycompany.myapp with your bundle id / package name.
1. Prepare
Section titled “1. Prepare”Store checklist
Section titled “Store checklist”Finish Store Setup first. Before testing, check that:
- Apple: the Paid Apps agreement is Active (Business → Agreements). Until it is,
GetProductsAsyncreturns nothing and no error. - Apple: every product has a price and at least one localization, and was created more than an hour ago. New products take that long to reach the sandbox.
- Google: a build that uses Play Billing has been uploaded to a testing track, and every product and subscription base plan is Active.
- Both: the app’s
ApplicationIdexactly matches the App Store Connect bundle id and the Play Console package name.
Server configuration for testing
Section titled “Server configuration for testing”Use these values while testing. Keep secrets in user-secrets (or a vault), not in appsettings.json:
{ "InAppPurchases": { "Apple": { "BundleId": "com.mycompany.myapp", "AppAppleId": 1234567890, "AllowSandbox": true }, "Google": { "PackageName": "com.mycompany.myapp", "RequirePubSubAuthentication": true, "PubSubAudience": "https://<your-tunnel>/iap/google", "PubSubServiceAccountEmail": "pubsub-push@<project>.iam.gserviceaccount.com" } }}cd MyServer# Optional for Apple: App Store Server API key, needed for test notificationsdotnet user-secrets set "InAppPurchases:Apple:IssuerId" "<issuer id>"dotnet user-secrets set "InAppPurchases:Apple:KeyId" "<key id>"dotnet user-secrets set "InAppPurchases:Apple:PrivateKey" "$(cat SubscriptionKey_XXXXXXXXXX.p8)"
# Required for Google: verifying purchases and reading notification detailsdotnet user-secrets set "InAppPurchases:Google:ServiceAccountJson" "$(cat service-account.json)"AllowSandboxmust betrue. Every test purchase on either platform is a sandbox purchase. Turn it off in production only if you never test against your production server.AppAppleIdis only checked for production notifications, but set it now so the server config is the same in every environment.- Leave out a store’s section entirely to switch its endpoint off (it returns
404).
2. Test the server on its own
Section titled “2. Test the server on its own”You don’t need a device for these steps, so this is the quickest way to find configuration mistakes.
-
Start the server and check the options validate.
Terminal window dotnet run --project MyServerOptions are validated at startup. A mistake stops the app with an
OptionsValidationExceptionthat names the setting, for example:Message Fix Apple.IssuerId, Apple.KeyId and Apple.PrivateKey must all be set…Set all three or none Apple.PrivateKey is not a valid PEM EC private key (.p8)Paste the whole .p8file, including the-----BEGIN PRIVATE KEY-----linesGoogle.PubSubAudience is required when Google.RequirePubSubAuthentication is trueSet the audience and service account email, or turn authentication off for local tests (next step) Configure at least one store…The InAppPurchasessection isn’t bound. Check the section name.Turn on debug logging for the library so every decision is logged:
{ "Logging": { "LogLevel": { "Shiny.Mobile.InAppPurchases.Server": "Debug" } } } -
Check the verification endpoint rejects junk. You should get
200withisValid: false, not a500:Terminal window curl -s -X POST http://localhost:5000/iap/verify \-H "Content-Type: application/json" \-d '{"platform":"AppStore","verificationData":"not-a-jws","productId":"remove_ads"}'# {"isValid":false,"error":"...","isActive":false,"platform":"AppStore",...}curl -s -X POST http://localhost:5000/iap/verify \-H "Content-Type: application/json" \-d '{"platform":"GooglePlay","verificationData":"not-a-token","productId":"remove_ads"}'For Google,
"error":"Google.ServiceAccountJson is required…"means the secret isn’t set. Any other result means the server reached the Play Developer API. A502means the API call failed: the service account isn’t invited in the Play Console yet, or its permissions haven’t taken effect. -
Send the Google webhook a test notification. For this local test only, turn off Pub/Sub authentication with an environment variable so you don’t need a Google-signed token:
Terminal window InAppPurchases__Google__RequirePubSubAuthentication=false dotnet run --project MyServerThen post a Pub/Sub push envelope. Its
datais the base64-encoded developer notification:Terminal window DATA=$(printf '%s' '{"version":"1.0","packageName":"com.mycompany.myapp","eventTimeMillis":"1760000000000","testNotification":{"version":"1.0"}}' | base64 | tr -d '\n')curl -i -X POST http://localhost:5000/iap/google \-H "Content-Type: application/json" \-d "{\"message\":{\"data\":\"$DATA\",\"messageId\":\"local-1\"},\"subscription\":\"projects/local/subscriptions/test\"}"Check Expected First post 200. Your handler receivesType = Test,RawType = TEST_NOTIFICATION. The log saysProcessed GooglePlay TEST_NOTIFICATION notification local-1.Same post again (same messageId)200, but the log saysDuplicate Google Play notification local-1 ignoredand your handler isn’t calledChange packageNamein the JSON400,package '...' does not match Google.PackageNameMake your handler throw 500, and the next post with the samemessageIdruns the handler again because a failed notification isn’t recorded as processedRestart with authentication on and post again 401,Missing bearer token -
Send the Apple webhook junk. Apple notifications are signed by Apple, so you can’t forge a valid one. Instead, check the endpoint rejects bad ones:
Terminal window curl -i -X POST http://localhost:5000/iap/apple -H "Content-Type: application/json" -d '{}'# 400 - body has no signedPayloadcurl -i -X POST http://localhost:5000/iap/apple -H "Content-Type: application/json" -d '{"signedPayload":"a.b.c"}'# 400 or 401 - not a valid Apple JWSReal Apple notifications come in step 3.4.
-
Expose the server over public HTTPS. The stores, Pub/Sub and a physical device all need a public HTTPS URL, and Android blocks plain
http://by default.Terminal window devtunnel host -p 5000 --allow-anonymous # or: ngrok http 5000Check
https://<your-tunnel>/iap/verifyanswers from outside your machine (for example from your phone’s browser, with aPOSTtool). Then point the app at the tunnel URL. In the sample, that’sSampleConfig.ServerUrl.Use a tunnel with a stable URL (a persistent dev tunnel, or a reserved ngrok domain). The URL goes into App Store Connect, the Pub/Sub subscription and the
PubSubAudiencesetting, so a new URL each session means updating all three. -
Unit test your event handler.
PurchaseEventis a plain record, so you can test yourIPurchaseEventHandlerwithout any store:[Fact]public async Task Refund_RevokesEntitlement(){var db = new FakeEntitlementDb();db.Grant(accountId, "premium_monthly", "otx-1");var handler = new MyPurchaseEventHandler(db);await handler.HandleAsync(new PurchaseEvent{NotificationId = "n-1",Platform = StorePlatform.AppStore,Type = PurchaseEventType.Refunded,RawType = "REFUND",ProductId = "premium_monthly",OriginalTransactionId = "otx-1",AccountToken = accountId}, CancellationToken.None);Assert.False(db.IsEntitled(accountId, "premium_monthly"));}Cover every
PurchaseEventTypeyou handle. Also call your handler twice with the same event and check it’s idempotent: the stores deliver at least once, and the in-memory de-duplicator doesn’t survive a restart or work across instances.
3. Apple end to end
Section titled “3. Apple end to end”3.1 Set up a sandbox tester and device
Section titled “3.1 Set up a sandbox tester and device”-
Create sandbox testers. In App Store Connect, go to Users and Access → Sandbox → Test Accounts → +. Use an email address that isn’t already an Apple Account (plus-addressing such as
you+iap1@example.comworks). Create at least two, because purchase history and introductory-offer eligibility are per tester. -
Use a physical device. Sandbox purchases on the Simulator are unreliable. StoreKit configuration files (
.storekit) only work when launching from an Xcode scheme, sodotnet buildcan’t use them. The server also rejectsXcodeandLocalTestingenvironment transactions, because Apple doesn’t sign them with its real certificates. -
Enable Developer Mode on the device: Settings → Privacy & Security → Developer Mode. Restart when it asks you to.
-
Deploy a development-signed build:
Terminal window dotnet build MyApp -f net10.0-ios -t:Run -p:RuntimeIdentifier=ios-arm64A distribution-signed build doesn’t use the sandbox, so make sure this one is development signed.
3.2 Test the mobile side
Section titled “3.2 Test the mobile side”-
Load products. Open your store screen.
CanMakePaymentsAsync()should returntrueandGetProductsAsyncshould return all three products with localizedDisplayPricevalues.If a product is missing, it isn’t an error: unknown ids are just left out. See Troubleshooting.
-
Make the first purchase. Buy
remove_ads. iOS asks you to sign in. Use the sandbox tester, not your real Apple Account (you don’t need to sign out of it).The purchase sheet must show
[Environment: Sandbox]. If it doesn’t, stop: you’re not in the sandbox.After this first purchase the tester appears under Settings → Developer → Sandbox Apple Account, which also has the sandbox controls.
-
Check the result in the app.
Check Expected PurchaseResult.StatusSuccessPurchase.EnvironmentSandboxPurchase.IsFinishedfalse, until you callFinishPurchaseAsyncPurchase.VerificationDataA long string with three dot-separated parts (a JWS) Purchase.AccountTokenThe Guidyou passed inPurchaseOptions.AccountTokenFor the server steps, log
VerificationDatain debug builds so you can copy it:#if DEBUGlogger.LogDebug("VerificationData for {ProductId}: {Data}", purchase.ProductId, purchase.VerificationData);#endif -
Buy the consumable twice. Buy
coins_100, grant it, then callFinishPurchaseAsync(purchase, consume: true). Buy it again: it should go through, because a finished consumable can be bought again. -
Subscribe. Buy
premium_monthly. If it has an introductory offer and this tester is eligible, the offer shows inStoreProduct.SubscriptionOffersand on the sheet.In the sandbox a 1-month subscription renews every 5 minutes, up to 12 times, then expires. You can change the rate under Settings → Developer → Sandbox Apple Account → Manage → Subscription Renewal Rate. Each renewal reaches
IPurchaseDelegatewith a newTransactionIdand the sameOriginalTransactionId, but only while the app is running. -
Test restore. Delete the app, reinstall it, and tap your Restore Purchases button (
RestorePurchasesAsync).remove_adsand the active subscription should come back fromGetEntitlementsAsync(). The consumable shouldn’t. -
Test a pending purchase. In App Store Connect, open the sandbox tester and turn on Interrupt Purchases for This Tester. Buy something:
PurchaseAsyncreturnsPending. Don’t grant anything yet.- Complete the interruption on the device (it asks you to accept terms).
- The completed purchase arrives through
IPurchaseDelegate.OnPurchaseUpdatedwithState = Purchased. Grant and finish it there.
Turn interruption off again afterwards.
-
Test Ask to Buy (optional). Create a Sandbox Test Family in App Store Connect, sign in as the child, and buy something. The result is
Pending. When the organizer approves it, the purchase arrives through the delegate. If they decline, nothing arrives and nothing is granted. -
Start over when you need to. Settings → Developer → Sandbox Apple Account → Manage → Clear Purchase History lets you buy
remove_adsagain and makes the tester eligible for introductory offers again. Sign out and back in on the device afterwards.
3.3 Test server verification
Section titled “3.3 Test server verification”-
Verify a real transaction. Copy the
VerificationDatayou logged in 3.2 and send it to the server. This works againstlocalhost: Apple transactions are checked offline against Apple’s root certificate, so no network call to Apple is made.Terminal window curl -s -X POST http://localhost:5000/iap/verify \-H "Content-Type: application/json" \-d '{"platform":"AppStore","verificationData":"<JWS>","productId":"remove_ads"}'Field Expected isValidtrueisActivetrue(not refunded; for a subscription, not yet expired)kindOneTimeforremove_adsandcoins_100,Subscriptionforpremium_monthlyenvironmentSandboxaccountTokenThe same Guidthe app passed inPurchaseOptions.AccountTokenoriginalTransactionIdMatches Purchase.OriginalTransactionIdon the device -
Check the server rejects tampered and mismatched data.
Change Expected Send "productId":"coins_100"with theremove_adsJWSisValid: false,Product id does not match the signed transactionChange one character in the middle part of the JWS isValid: false(the signature check fails)Set Apple:AllowSandboxtofalseand restartisValid: false,Environment 'Sandbox' is not acceptedSet Apple:BundleIdto something else and restartisValid: false, bundle id mismatch -
Verify from the app. Point the app at your tunnel URL and make a purchase. The app should call
/iap/verify, grant only whenisValidandisActivearetrue, and then finish the purchase. Check the server log for the request and the app log for the grant. -
Check the app recovers when the server is down. Stop the server (or point the app at a bad URL) and buy
coins_100. Verification fails, so the app must not finish the purchase. Start the server again and relaunch the app:GetUnfinishedPurchasesAsync()returns the purchase, and it’s verified, granted and finished exactly once.
3.4 Test App Store Server Notifications
Section titled “3.4 Test App Store Server Notifications”-
Set the sandbox URL. In App Store Connect, open your app → App Information → App Store Server Notifications. Set Sandbox Server URL to
https://<your-tunnel>/iap/appleand choose Version 2. Sandbox purchases now send notifications to your server. -
Request a test notification. This needs the App Store Server API key (
IssuerId,KeyId,PrivateKey). CallIAppleStoreClient.RequestTestNotificationAsync(sandbox: true). The sample server exposes it as an endpoint:Terminal window curl -X POST "http://localhost:5000/iap/apple/test?sandbox=true"# {"token":"<testNotificationToken>"}Within a few seconds Apple posts to the Sandbox URL. Your handler receives
Type = Test,RawType = TEST, and the endpoint returns200. If nothing arrives, check the URL in App Store Connect and that the tunnel is up. -
Make purchases and check the events. With the app signed in as the sandbox tester:
Action on the device RawTypeTypeBuy coins_100orremove_adsONE_TIME_CHARGEPurchasedSubscribe to premium_monthlySUBSCRIBED/INITIAL_BUYPurchasedWait about 5 minutes DID_RENEWRenewedCancel in Manage Subscriptions ( ShowManageSubscriptionsAsync())DID_CHANGE_RENEWAL_STATUS/AUTO_RENEW_DISABLEDAutoRenewDisabledTurn auto-renew back on DID_CHANGE_RENEWAL_STATUS/AUTO_RENEW_ENABLEDAutoRenewEnabledLet a cancelled subscription run out EXPIRED/VOLUNTARYExpiredSubscribe again after it expires SUBSCRIBED/RESUBSCRIBEPurchasedSwitch to another subscription in the same group DID_CHANGE_RENEWAL_PREF/...orSUBSCRIBED/...PlanChanged/PurchasedFor each event, check:
EnvironmentisSandbox.AccountTokenis theGuidthe app passed, so you can find the user without a device.OriginalTransactionIdmatches what/iap/verifyreturned for that purchase.ExpiresAtmoves forward on each renewal.
-
Check retries. Make your handler throw once, then make a purchase. The endpoint returns
500and Apple retries later (Apple’s retry schedule is measured in hours, so this is mostly to confirm nothing is recorded as processed). Fix the handler: the redelivery is processed normally. -
Refunds. You can’t reliably trigger
REFUNDorREVOKEin the sandbox. Cover them with the handler unit tests. In production, refunds are requested at reportaproblem.apple.com.
3.5 TestFlight (optional)
Section titled “3.5 TestFlight (optional)”TestFlight builds also use the sandbox, so you can run the steps above against the build you’re about to ship:
- Build and upload an
.ipa(see the sample readme for thedotnet publishcommand). - Install it from TestFlight. Purchases there are sandbox purchases and the sheet shows the sandbox environment.
- To use the sandbox controls (renewal rate, clear purchase history) with a TestFlight build, sign out of Settings → [your name] → Media & Purchases and sign in under Settings → Developer → Sandbox Apple Account. A dedicated test device makes this easier.
4. Google end to end
Section titled “4. Google end to end”4.1 Set up a license tester and device
Section titled “4.1 Set up a license tester and device”-
Add license testers. In the Play Console, go to Settings → License testing and add the Google accounts you’ll test with. License testers pay with test cards and are never charged.
-
Choose a device. Use a physical device, or an emulator created from a Google Play system image (it has the Play Store icon). A “Google APIs” image has no Play Store, so billing doesn’t work. Sign in to the Play Store with a license tester account.
If the device has several Google accounts, the purchase is made by the account that installed the app. Keep only the tester account on a test device.
-
Deploy. License testers can sideload a debug build as long as the package name matches the Play Console app:
Terminal window dotnet build MyApp -f net10.0-android -t:RunTo test the exact build you’ll ship, install from the internal testing track instead. A new release can take a while to reach testers.
4.2 Test the mobile side
Section titled “4.2 Test the mobile side”-
Load products.
CanMakePaymentsAsync()should returntrueandGetProductsAsyncshould return all three products. Forpremium_monthly,SubscriptionOffershas one entry per base plan and per offer the tester is eligible for, each with anOfferToken. -
Make the first purchase. Buy
remove_ads. The Play sheet must show test payment methods such as “Test card, always approves”. If it shows your real cards, stop: the account isn’t a license tester. -
Check the result in the app.
Check Expected PurchaseResult.StatusSuccessPurchase.VerificationDataThe purchase token (also in OriginalTransactionId)Purchase.TransactionIdThe order id, GPA.xxxx-xxxx-xxxx-xxxxxPurchase.AccountTokenThe Guidyou passed inPurchaseOptions.AccountTokenPurchase.EnvironmentUnknown, because Google doesn’t tell the device. The server can tell (next section).Purchase.ExpirationDatenull, because Google doesn’t expose expiry on the device -
Finish quickly, or lose the purchase. Google refunds a purchase that isn’t acknowledged. For license testers that happens after about 5 minutes, not 3 days. Check
FinishPurchaseAsyncruns after the grant: if purchases disappear and show as refunded in Order management, it didn’t. -
Buy the consumable twice. Buy
coins_100and finish it withconsume: true. Buy it again: it should go through. If you finish a consumable withconsume: false, the next purchase returnsAlreadyOwned. Google has no product-level “consumable” setting; it depends only on how you finish. -
Try the test cards. Each one exercises a different path:
Test card Expected in the app Always approves SuccessAlways declines InAppPurchaseExceptionSlow test card, approves after a few minutes Pending, then the purchase arrives throughIPurchaseDelegatewithState = Purchased. Grant and finish it there.Slow test card, declines after a few minutes Pending, then nothing. Restart the app and check nothing was granted. -
Subscribe. Buy
premium_monthly(pass anOfferTokento pick a base plan or offer). Test subscriptions renew fast, at most 6 times:Period Test renewal 1 week, 1 month 5 minutes 3 months 10 minutes 6 months 15 minutes 1 year 30 minutes Free trials last 3 minutes, a grace period 5 minutes and account hold 10 minutes.
-
Test restore and already-owned. Buy
remove_adsagain: the result isAlreadyOwned. Clear the app’s data or reinstall, then callRestorePurchasesAsync():remove_adsand the active subscription come back. -
Use Play Billing Lab for edge cases (optional). The Play Billing Lab app, signed in as the license tester, changes the Play country, resets free-trial eligibility and puts a subscription into payment-declined states (grace period, account hold). Its settings expire after 2 hours.
4.3 Test server verification
Section titled “4.3 Test server verification”Google purchase tokens can only be checked with the Play Developer API, so ServiceAccountJson must be set and the service account invited in the Play Console (Users and permissions, with View financial data and Manage orders and subscriptions). New permissions can take up to a day to start working.
-
Verify a real purchase token. Copy
VerificationDatafrom the app’s debug log:Terminal window curl -s -X POST http://localhost:5000/iap/verify \-H "Content-Type: application/json" \-d '{"platform":"GooglePlay","verificationData":"<purchase token>","productId":"premium_monthly"}'Field Expected isValidtrueisActivetruekindSubscriptionforpremium_monthly,OneTimefor the othersenvironmentSandbox, because Google marks license-tester purchases as test purchasesexpiresAtThe next renewal time (subscriptions only), about 5 minutes away isAcknowledgedfalsebefore the app finishes the purchase,trueafteraccountTokenThe same Guidthe app passedA
502means the server couldn’t reach the Play Developer API or was refused (check the server log for the HTTP status).401/403from Google almost always means the service account’s Play Console permissions haven’t taken effect yet. -
Check the server rejects mismatches. Send the
premium_monthlytoken with"productId":"remove_ads": you getisValid: false. Send a made-up token:isValid: false. -
Watch a subscription change state. Verify the same subscription token again after cancelling it in Manage Subscriptions:
isActivestaystrueuntilexpiresAt, then becomesfalse. -
Verify from the app, and test recovery. Point the app at the tunnel URL and repeat steps 3 and 4 of 3.3: the app grants only after verification, and a purchase made while the server is down is recovered on the next launch. Do the recovery test quickly, within the 5-minute acknowledgement window.
4.4 Test Real-time Developer Notifications
Section titled “4.4 Test Real-time Developer Notifications”-
Check the Pub/Sub setup. You need (see Store Setup):
- A topic, with
google-play-developer-notifications@system.gserviceaccount.comas Pub/Sub Publisher. - The topic name entered under Play Console → Monetize with Play → Monetization setup.
- A push subscription on the topic, pointing at
https://<your-tunnel>/iap/google, with authentication enabled. - The push subscription’s service account and audience copied to
PubSubServiceAccountEmailandPubSubAudience. If you left the audience blank in Pub/Sub, it defaults to the push endpoint URL.
Restart the server with
RequirePubSubAuthenticationback on (the default). - A topic, with
-
Publish a message yourself. This checks Pub/Sub → tunnel → authentication → your handler before involving the Play Console:
Terminal window gcloud pubsub topics publish play-billing \--message='{"version":"1.0","packageName":"com.mycompany.myapp","testNotification":{"version":"1.0"}}'Pub/Sub wraps and base64-encodes the message for you. The server should return
200and your handler should receiveType = Test.If the server logs
Google Pub/Sub push rejected - ...and returns401, the reason tells you what’s wrong. Usually the audience or service account email doesn’t match the push subscription exactly (includinghttps://and any trailing slash). -
Send a Play Console test notification. In Monetization setup, click Send test notification. You get another
TEST_NOTIFICATIONevent. This confirms Google Play can publish to your topic. -
Make purchases and check the events. With the license tester:
Action RawTypeTypeBuy coins_100orremove_adsONE_TIME_PRODUCT_PURCHASEDPurchasedSubscribe to premium_monthlySUBSCRIPTION_PURCHASEDPurchasedWait about 5 minutes SUBSCRIPTION_RENEWEDRenewedCancel in Manage Subscriptions SUBSCRIPTION_CANCELEDAutoRenewDisabledResubscribe before it runs out SUBSCRIPTION_RESTARTEDRestartedLet a cancelled subscription run out SUBSCRIPTION_EXPIREDExpiredSlow card that declines ONE_TIME_PRODUCT_CANCELEDorSUBSCRIPTION_PENDING_PURCHASE_CANCELEDPendingCanceledPayment declined (Play Billing Lab) SUBSCRIPTION_IN_GRACE_PERIOD, thenSUBSCRIPTION_ON_HOLDGracePeriodStarted,OnHoldOrder management → Refund (with “Remove entitlement”) VOIDED_PURCHASERefundedOrder management → Revoke a subscription SUBSCRIPTION_REVOKEDRevokedFor each event, check:
EnvironmentisSandbox. The server fetched the purchase from the Play Developer API and saw the test-purchase flag.AccountTokenis theGuidthe app passed.ExpiresAtandIsAutoRenewingare set for subscriptions.OriginalTransactionIdis the purchase token.
If one-time product events don’t arrive, check the notification settings under Monetization setup: Google can be set to send only subscription and voided-purchase notifications.
-
Check retries. Make your handler throw once and make a purchase. The endpoint returns
500, Pub/Sub retries with backoff, and the retry is processed once the handler works. In Google Cloud, the push subscription’s metrics show failed and retried deliveries. -
Replay a real notification locally (optional). To debug your handler with real Google data and no Pub/Sub, post a notification with a real purchase token straight to a local server with authentication off. The server fetches the purchase from the Play Developer API as usual:
Terminal window DATA=$(printf '%s' '{"version":"1.0","packageName":"com.mycompany.myapp","eventTimeMillis":"1760000000000","subscriptionNotification":{"version":"1.0","notificationType":4,"purchaseToken":"<purchase token>","subscriptionId":"premium_monthly"}}' | base64 | tr -d '\n')curl -i -X POST http://localhost:5000/iap/google \-H "Content-Type: application/json" \-d "{\"message\":{\"data\":\"$DATA\",\"messageId\":\"replay-$(date +%s)\"},\"subscription\":\"projects/local/subscriptions/test\"}"notificationType4isSUBSCRIPTION_PURCHASEDand2isSUBSCRIPTION_RENEWED. UseoneTimeProductNotificationwith"notificationType":1and"sku":"remove_ads"for one-time products. Change themessageIdeach time, or the de-duplicator ignores the repeat.
5. Test plan
Section titled “5. Test plan”Run every row on both platforms before shipping. “Server” means the event your IPurchaseEventHandler receives.
| # | Scenario | App | Server |
|---|---|---|---|
| 1 | Buy coins_100 twice |
Granted twice; the second purchase works | Purchased × 2, different TransactionIds |
| 2 | Buy remove_ads, reinstall, Restore Purchases |
Unlock comes back | Nothing new |
| 3 | Buy remove_ads again |
iOS: the sheet says you own it · Android: AlreadyOwned |
Nothing new |
| 4 | Subscribe to premium_monthly, wait for renewals |
Active; renewals reach the delegate while running | Purchased, then Renewed every ~5 min; ExpiresAt advances |
| 5 | Cancel in Manage Subscriptions, wait | Active until it expires, then not | AutoRenewDisabled, then Expired |
| 6 | Cancel the purchase sheet | Cancelled, nothing granted |
Nothing |
| 7 | Pending: iOS interrupted purchase / Ask to Buy · Android slow card | Pending, then granted through the delegate |
Purchased once it clears |
| 8 | Kill the app after paying, before the grant | Recovered on relaunch, granted once | Purchased once |
| 9 | Server down during a purchase | Not finished; recovered when the server is back | /iap/verify succeeds on the retry |
| 10 | Airplane mode, then purchase | InAppPurchaseException, ErrorCode = Network |
Nothing |
| 11 | Tampered VerificationData sent to /iap/verify |
Nothing granted | isValid: false |
| 12 | Same notification delivered twice | — | Handler runs once |
| 13 | Handler throws | — | 500; the store retries; processed once fixed |
| 14 | Refund · Android Order management, iOS handler unit test | Entitlement removed on the next check | Refunded / Revoked |
| 15 | Android: don’t finish a purchase | Refunded after ~5 minutes (testers) | VOIDED_PURCHASE |
Troubleshooting
Section titled “Troubleshooting”Mobile
Section titled “Mobile”| Symptom | Cause |
|---|---|
| No products load (Apple) | Paid Apps agreement not active, product missing a price or localization, product created less than an hour ago, bundle id mismatch, or running on the Simulator |
| No products load (Google) | Product or base plan not Active, account isn’t a license tester, package name mismatch, or an emulator without the Play Store. Clearing the Play Store app’s cache helps after catalog changes. |
No [Environment: Sandbox] on the sheet |
Distribution-signed build, or signed in with a real Apple Account |
| Real cards on the Play sheet | The account isn’t in License testing, or another account on the device installed the app |
DeveloperError on Android |
A subscription without an active base plan, or a stale OfferToken |
| Android purchases disappear after a few minutes | Never acknowledged: FinishPurchaseAsync didn’t run |
AlreadyOwned for a consumable |
It was finished with consume: false. Call GetUnfinishedPurchasesAsync() / GetEntitlementsAsync() and finish it with consume: true. |
Server
Section titled “Server”| Symptom | Cause |
|---|---|
| App won’t start | Options validation failed. The exception names the setting. |
/iap/apple or /iap/google returns 404 |
That store’s section isn’t configured |
Apple: Environment 'Sandbox' is not accepted |
AllowSandbox is false |
Apple: Environment 'Xcode' is not accepted |
The transaction came from a StoreKit configuration file. Test with a sandbox account. |
Apple: Apple.AppAppleId must be configured to accept Production notifications |
Set AppAppleId. It only matters for production. |
| Apple: nothing arrives | Sandbox URL not set or not Version 2 in App Store Connect, or the tunnel is down |
Google: 401, Missing bearer token |
Push subscription authentication is off, or a request that didn’t come from Pub/Sub |
Google: 401, audience or email mismatch |
PubSubAudience / PubSubServiceAccountEmail don’t exactly match the push subscription |
Google: 400, package mismatch |
Google.PackageName doesn’t match the app |
Google: 502 |
The Play Developer API call failed. Usually the service account isn’t invited, or its permissions haven’t taken effect yet. |
Google: Environment = Unknown on events |
ServiceAccountJson isn’t set, so the server can’t fetch the purchase. Test notifications are always Unknown. |
Before you go to production
Section titled “Before you go to production”- App Store Connect Production Server URL set to your production
/iap/apple, Version 2, andAppAppleIdconfigured. - The production Pub/Sub push subscription points at your production
/iap/google, with the production audience on the server. -
/iap/verify(or your own endpoint) uses.RequireAuthorization()and grants to the authenticated user, never to an id in the request. -
AccountTokenis your real user id. - Nothing grants a
Pendingpurchase, and every purchase is finished after the grant. - With more than one server instance, a shared
IPurchaseEventDeduplicatoris registered (UseDeduplicator<T>()). - On Google Play, one purchase made with an account that isn’t a license tester, then refunded from Order management.


