Introduction
KlugBilling sells one-time products and subscriptions from your AIR app. On iPhone and iPad it talks to the App Store through StoreKit, on Android to Google Play through the Google Play Billing Library 8.3.0, and your ActionScript code stays the same for both stores.
- Products: one-time products and auto-renewing subscriptions with localized titles, descriptions and prices straight from the store
- Subscriptions: billing periods, offers and pricing phases (free trials, introductory prices)
- Purchases: every transaction state as an event, and finishPurchase() to close a transaction once you have delivered
- Restore: restore earlier purchases on iOS, list owned purchases on Android
- Verification: the iOS app receipt, Google's signed purchase data, signature and purchase token for your server; on Android purchases are also checked on the device against your Play public key
Self-contained: no core library or dependency ANEs. One ANE is all you add. KlugBilling sells the subscriptions of our apps on iOS and Android.
| Extension ID | com.klug.billing |
| Current version | 1.0.0 |
| Platforms | Android (arm, arm64, x86, x86_64), iOS (devices). On other platforms, including the iOS Simulator and desktop, InAppBilling.isSupported is false and every call returns false or does nothing. |
| ActionScript API | Package com.klug.billing with the entry point InAppBilling.service; events in com.klug.billing.events |
| Store back-ends | iOS: StoreKit (SKPaymentQueue). Android: Google Play Billing Library 8.3.0 |
| Delivery | com.klug.billing.ane (the extension), the ActionScript library (SWC) for code completion, and this documentation |
Requirements
| AIR SDK | 51.x (HARMAN) |
| iOS | iOS 13 or later, real devices. Products created in App Store Connect, and the Paid Applications agreement active. |
| Android | Android 6.0 (API 23) or later with the Google Play Store. Products and subscriptions created in the Google Play Console. |
| Google Play public key | The base64 RSA public key of your app from the Play Console (Monetization setup). KlugBilling checks the signature of every purchase with it. |
| Google Play Billing | Play Billing Library 8.3.0, added by Gradle when ADT packages your Android app. The build machine needs access to Google's Maven repository (or the library in its local Gradle cache). |
| Server (recommended) | Verifies the iOS app receipt or Google's signed purchase data before you unlock anything of value |
Add the extension
1 · Copy the ANE and declare it
Copy com.klug.billing.ane into your project's extension folder (the folder you pass to ADT with -extdir, or the one your IDE links as native extensions) and add the extension ID to the application descriptor:
<extensions>
<extensionID>com.klug.billing</extensionID>
</extensions>
2 · Nothing else
No manifest entries, no activities and no Info.plist keys are needed. On Android the extension declares the Google Play Billing Library 8.3.0 as a Gradle dependency, so ADT adds it automatically when it packages your app, together with everything the library brings along. On iOS the extension links Apple's StoreKit framework.
Getting started
All calls go through InAppBilling.service. Every call returns at once; the results arrive as events on the service. Add your listeners first, then call setup(), so that no transaction that the store replays on start is missed.
import com.klug.billing.*; import com.klug.billing.events.*; private var store:InAppBillingService = InAppBilling.service; // the single service: never call new InAppBillingService() public function initStore():void { if ( !InAppBilling.isSupported ) // iOS and Android devices only { hideShop(); // your app function return; } store.addEventListener( InAppBillingEvent.SETUP_SUCCESS, onSetup ); store.addEventListener( InAppBillingEvent.SETUP_FAILURE, onSetupFailed ); store.addEventListener( ProductEvent.PRODUCTS_LOADED, onProductsLoaded ); store.addEventListener( PurchaseEvent.PURCHASES_UPDATED, onPurchasesUpdated ); // before setup() store.addEventListener( PurchaseEvent.PURCHASE_FAILED, onPurchaseFailed ); store.setup( createBillingService() ); // see "Set up the store" }
The guides below fill in each of these functions. Together they form a complete store: set up, check availability, load products, buy, verify, deliver and finish.
Guides
Set up the store
setup(service) connects to the store of the platform the app runs on and reports SETUP_SUCCESS or SETUP_FAILURE. Describe the store with a BillingService: the service type for the platform and, on Android, your Google Play public key.
| iOS | Service type InAppBillingServiceTypes.APPLE_INAPP_PURCHASE, no key. setup() registers the transaction observer with StoreKit and always reports SETUP_SUCCESS. |
| Android | Service type InAppBillingServiceTypes.GOOGLE_PLAY_INAPP_BILLING and setGooglePlayPublicKey(key). setup() connects to Google Play and reports SETUP_FAILURE with an error code when Play is not available, for example service_unavailable when no Google account is signed in to the Play Store. |
// Play Console: your app > Monetization setup > Licensing (base64 RSA public key) private static const PLAY_PUBLIC_KEY:String = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA..."; private function createBillingService():BillingService { if ( store.platform == "android" ) { return new BillingService() .setServiceType( InAppBillingServiceTypes.GOOGLE_PLAY_INAPP_BILLING ) .setGooglePlayPublicKey( PLAY_PUBLIC_KEY ); } return new BillingService() .setServiceType( InAppBillingServiceTypes.APPLE_INAPP_PURCHASE ); } private function onSetupFailed( e:InAppBillingEvent ):void { trace( "store setup failed: " + e.errorCode + " " + e.message ); hideShop(); }
setup() always uses the store of the current platform. The service type tells your code and isServiceSupported(type) which store you mean; isServiceSupported() is true for APPLE_INAPP_PURCHASE on iOS and for GOOGLE_PLAY_INAPP_BILLING on Android. dispose() disconnects from the store; you can call setup() again later.
The Android public key is used on the device: every purchase whose signature does not match is reported as failed with the message invalid purchase signature and is not delivered. Without a key the device check is skipped, and so it is when the key cannot be read (a warning is written to logcat under the tag KlugBilling). Your server should check the purchase as well; see Verify purchases on your server.
Check availability
After SETUP_SUCCESS, checkAvailability(callback) tells you whether purchases can be made. The result is one of the InAppBillingAvailability constants; it goes to the optional callback and arrives as AvailabilityEvent.COMPLETE as well.
| AVAILABLE | iOS: the device allows payments. Android: connected to Google Play, and Play supports subscriptions. |
| NOT_AVAILABLE | iOS: purchases are disabled, for example with Screen Time restrictions. Android: not connected to Google Play. |
| STORE_UPGRADE_REQUIRED | Android: connected, but the Play Store app on the device is too old for subscriptions. |
private function onSetup( e:InAppBillingEvent ):void { store.checkAvailability( function( result:String ):void { switch ( result ) { case InAppBillingAvailability.AVAILABLE: loadProducts(); break; case InAppBillingAvailability.STORE_UPGRADE_REQUIRED: showMessage( "Please update the Google Play Store app to subscribe." ); // your app break; default: hideShop(); } }); }
On iOS the callback and the event come right away, within the call. On Android the callback is called within the call and the event follows asynchronously. The property canMakePayments answers the same question directly: on iOS whether the device allows payments, on Android whether the connection to Google Play is up.
Load products and subscriptions
getProducts(productIds, subscriptionIds) loads the store data of your products. Put one-time products in the first array and subscriptions in the second. Google Play queries the two kinds separately, so a subscription in the first array (or a one-time product in the second) is reported as unknown on Android; on iOS the split makes no difference. Use the same product IDs in App Store Connect and in the Play Console, such as com.example.pro.yearly, and one code path serves both stores.
| PRODUCTS_LOADED | e.products holds the Product objects the store knows. They are also kept by the service: getProduct(id) returns them later. The array can be empty. |
| INVALID_PRODUCT | Follows PRODUCTS_LOADED when some IDs are unknown to the store. e.data lists them; e.errorCode is item_unavailable. |
| PRODUCTS_FAILED | The store could not be asked, for example without a network. e.errorCode and e.message say why. On Android it is reported only when no product could be loaded at all. |
private function loadProducts():void { store.addEventListener( ProductEvent.INVALID_PRODUCT, onInvalidProducts ); store.addEventListener( ProductEvent.PRODUCTS_FAILED, onProductsFailed ); store.getProducts( [ "com.example.removeads" ], // one-time products [ "com.example.pro.monthly", "com.example.pro.yearly" ] ); // subscriptions } private function onProductsLoaded( e:ProductEvent ):void { for each ( var p:Product in e.products ) { // localized by the store, e.g. "Pro Yearly · 19,99 € / 1 year" var label:String = p.title + " · " + p.priceString; if ( p.type == Product.TYPE_SUBSCRIPTION && p.subscriptionPeriod != null ) label += " / " + p.subscriptionPeriod.toString(); addShopButton( p.id, label ); // your app function } } private function onInvalidProducts( e:ProductEvent ):void { trace( "unknown product ids: " + e.data.join( ", " ) ); } private function onProductsFailed( e:ProductEvent ):void { trace( "products failed: " + e.errorCode + " " + e.message ); }
Show priceString as it is: it is formatted by the store in the user's currency and language. price (a Number) and currencyCode (ISO 4217, such as EUR) are there for your own calculations, for example a monthly price of a yearly plan.
| type | Product.TYPE_SUBSCRIPTION for auto-renewing subscriptions, otherwise Product.TYPE_INAPP. On iOS a product counts as a subscription when the App Store reports a billing period, so non-renewing subscriptions are TYPE_INAPP. |
| subscriptionPeriod | The regular billing period, e.g. 1 x month or 1 x year. null for one-time products. |
| price, priceString | For subscriptions: the regular price, without introductory offers. |
| title | On Android, Google Play adds the app name in brackets to the title. |
Subscription periods, offers and pricing phases
A subscription Product carries subscriptionOffers, an Array of SubscriptionOffer. subscriptionOffers[0] is always the regular price. Each offer has an id, tags and pricingPhases: the SubscriptionPhase objects in the order the customer goes through them, for example a free week, then the regular yearly price.
| iOS | [0]: id "", no tags, one phase with the regular price. If the product has an introductory offer in App Store Connect, [1] follows: id "introductory", tag "free-trial" for a free trial or "introductory" for a reduced price, and two phases (the introductory phase, then the regular price). The introductory offer is listed whether or not the user is still eligible; StoreKit applies it on its own at purchase when the user is eligible. |
| Android | One entry per base plan and offer that Google Play returns, base plans first. id is the offer ID, or the base plan ID for a base plan; tags are the tags you set in the Play Console; offerToken is Google's token for the purchase. Google Play returns only the offers the user is eligible for. |
| recurrenceMode | INFINITE_RECURRING (1): the phase repeats until cancelled, the regular price. FINITE_RECURRING (2): the phase repeats numberOfPeriods times. NON_RECURRING (3): charged once (Android). On iOS the introductory phase is reported as FINITE_RECURRING. |
| numberOfPeriods | How many billing periods the phase lasts; 0 for INFINITE_RECURRING. |
| subscriptionPeriod | Length of one billing period of the phase. |
| price, priceString, currencyCode | Price of one period of the phase; 0 for a free trial. |
private function describeOffers( p:Product ):void { for each ( var offer:SubscriptionOffer in p.subscriptionOffers ) { var parts:Array = []; for each ( var phase:SubscriptionPhase in offer.pricingPhases ) { var period:String = phase.subscriptionPeriod ? phase.subscriptionPeriod.toString() : ""; if ( phase.recurrenceMode == SubscriptionPhase.INFINITE_RECURRING ) parts.push( phase.priceString + " every " + period ); else if ( phase.recurrenceMode == SubscriptionPhase.FINITE_RECURRING ) parts.push( phase.priceString + " every " + period + " for " + phase.numberOfPeriods + " period(s)" ); else parts.push( phase.priceString + " once for " + period ); } // e.g. "offer 'introductory' [free-trial]: 0,00 € every 1 week for 1 period(s), then 19,99 € every 1 year" trace( "offer '" + offer.id + "' [" + offer.tags.join( "," ) + "]: " + parts.join( ", then " ) ); } }
Buy, deliver, finish
Load the product first: makePurchase() only works for products from a PRODUCTS_LOADED event. checkPurchaseRequestValid(request) is true when the product was loaded and canMakePayments is true. makePurchase(request) opens the store's purchase sheet and returns true; it returns false when the product was not loaded or (iOS) payments are not allowed.
public function buy( productId:String ):void { var request:PurchaseRequest = new PurchaseRequest().setProductId( productId ); if ( !store.checkPurchaseRequestValid( request ) || !store.makePurchase( request ) ) showMessage( "The store is not available right now." ); }
From then on every step of the transaction arrives as PurchaseEvent.PURCHASES_UPDATED. e.data is an Array of Purchase; check transactionState of each one:
| STATE_PURCHASING | iOS: the purchase sheet is open. Wait. Do not finish. |
| STATE_PURCHASED | Paid. Verify, deliver, then finishPurchase(). |
| STATE_RESTORED | iOS: an earlier purchase came back through restorePurchases(). Deliver, then finishPurchase(). |
| STATE_DEFERRED | Waiting for approval or payment: Ask to Buy on iOS, a pending payment method (such as cash at a shop) on Android. Tell the user; do not finish. The same transaction comes again as STATE_PURCHASED or STATE_FAILED. |
| STATE_FAILED | The purchase failed. errorCode and errorMessage say why. Call finishPurchase() right away. |
| STATE_CANCELLED | The user closed the purchase sheet. Call finishPurchase() right away. |
| STATE_UNKNOWN | Android: Google Play reports no defined state. Ignore it. |
private function onPurchasesUpdated( e:PurchaseEvent ):void { for each ( var p:Purchase in e.data ) handlePurchase( p ); } private function handlePurchase( p:Purchase ):void { switch ( p.transactionState ) { case Purchase.STATE_PURCHASING: showSpinner(); // your app break; case Purchase.STATE_DEFERRED: hideSpinner(); showMessage( "Your purchase is waiting for approval." ); break; case Purchase.STATE_PURCHASED: case Purchase.STATE_RESTORED: verifyOnServer( p, function( valid:Boolean ):void { // see "Verify purchases" hideSpinner(); if ( valid ) unlock( p.productId ); // your app store.finishPurchase( p ); // close it, or the store sends it again }); break; case Purchase.STATE_FAILED: case Purchase.STATE_CANCELLED: hideSpinner(); store.finishPurchase( p ); break; } }
When to call finishPurchase()
finishPurchase(purchase) closes a transaction. Call it once for every PURCHASED, RESTORED, FAILED and CANCELLED transaction, and never for PURCHASING or DEFERRED. Call it only after the product has been delivered, that is after your server confirmed the purchase and your app saved the unlock: once a transaction is finished, the store does not send it again.
| iOS | Finishes the StoreKit transaction. A transaction that is not finished is delivered again at every app start until it is. |
| Android | Acknowledges the purchase with Google Play. Google Play refunds a purchase that is not acknowledged within three days. Failed and cancelled purchases have no Google record; finishing them only closes them in KlugBilling. A purchase that is already acknowledged finishes without a request to Google. |
| Result | PurchaseEvent.FINISH_SUCCESS or FINISH_FAILED, with the purchase in e.data. FINISH_FAILED with the message unknown or already finished transaction (iOS) or unknown or already finished purchase (Android) means the purchase was finished before or is not finishable. If the acknowledgement fails on Android, the purchase stays open and you can call finishPurchase() with it again. |
Use the Purchase objects from the events of the current app session. Transactions from earlier sessions are delivered again after setup(); finish those new objects.
Failed and cancelled purchases
A failed or cancelled purchase arrives twice: first in PURCHASES_UPDATED with STATE_FAILED or STATE_CANCELLED (finish it there, as above), then as PurchaseEvent.PURCHASE_FAILED with errorCode, message and the purchase in e.data. Use PURCHASE_FAILED to tell the user. One exception on Android: ITEM_ALREADY_OWNED arrives only as PURCHASE_FAILED, with an empty e.data.
private function onPurchaseFailed( e:PurchaseEvent ):void { hideSpinner(); switch ( e.errorCode ) { case ErrorCodes.USER_CANCELLED: break; // the user decided; say nothing case ErrorCodes.ITEM_ALREADY_OWNED: // Android: already bought restore(); // see "Restore purchases" break; case ErrorCodes.NOT_ALLOWED: // iOS: payments disabled on this device showMessage( "Purchases are not allowed on this device." ); break; case ErrorCodes.NETWORK_ERROR: case ErrorCodes.SERVICE_UNAVAILABLE: showMessage( "The store cannot be reached. Please try again later." ); break; default: showMessage( "The purchase did not go through. " + e.message ); } }
Deferred purchases
A deferred purchase is neither paid nor failed yet. On iOS this is Ask to Buy: a family organiser has to approve the purchase. On Android it is a pending purchase: the user chose a payment method that completes later, such as cash at a shop; Google Play allows this for one-time products. In both cases the purchase arrives with STATE_DEFERRED. Do not unlock and do not finish. When the purchase completes, it arrives again: while your app runs through PURCHASES_UPDATED, otherwise after the next setup(), as STATE_PURCHASED (or, on iOS, STATE_FAILED if it was declined).
Buy a subscription with an offer
To buy a subscription with a specific offer, wrap the SubscriptionOffer in a SubscriptionOfferRequest and attach it to the PurchaseRequest. Without an offer request the regular price is bought.
| Android | The offer with the requested id is bought with its offer token. Without an offer request, or with an ID the product does not have, the first base plan is bought. |
| iOS | The regular price and the introductory offer go through the same plain payment: StoreKit applies the introductory price on its own when the user is eligible. The offer request therefore makes no difference on iOS. Promotional offers (signed offers for existing subscribers) are not supported in 1.0.0. |
// e.g. buyWithOffer( "com.example.pro.yearly", "free-trial" ) public function buyWithOffer( productId:String, tag:String ):void { var product:Product = store.getProduct( productId ); if ( product == null || product.subscriptionOffers == null ) return; var chosen:SubscriptionOffer = product.subscriptionOffers[ 0 ]; // the regular price for each ( var offer:SubscriptionOffer in product.subscriptionOffers ) { if ( offer.tags.indexOf( tag ) >= 0 ) { chosen = offer; break; } } store.makePurchase( new PurchaseRequest() .setProductId( productId ) .setSubscriptionOfferRequest( new SubscriptionOfferRequest().setSubscriptionOffer( chosen ) ) ); }
On Android, tag your offers in the Play Console (for example free-trial) so the same code finds them on both platforms; on iOS KlugBilling sets the tag free-trial or introductory itself.
Restore purchases
Apps that sell non-consumable products or subscriptions should offer a way to restore them, for example a "Restore Purchases" button. The two stores work differently here, and isGetPurchasesSupported tells you which way to go:
| iOS: restorePurchases() | Asks the App Store for the earlier purchases of the Apple ID (non-consumable products and subscriptions). Each one arrives in PURCHASES_UPDATED with STATE_RESTORED; deliver and finish it there. Afterwards RESTORE_PURCHASES_SUCCESS or RESTORE_PURCHASES_FAILED follows, both as PurchaseEvent and as InAppBillingEvent. The App Store may ask the user to sign in. |
| Android: getPurchases() | Lists what the Google account owns: active subscriptions and one-time products. Result: GET_PURCHASES_COMPLETE with the purchases in e.data, or GET_PURCHASES_FAILED. Owned purchases have STATE_PURCHASED, also when they were acknowledged long ago; pending ones have STATE_DEFERRED. Purchases with an invalid signature are left out. |
On Android, restorePurchases() only reports RESTORE_PURCHASES_SUCCESS or RESTORE_PURCHASES_FAILED and delivers no purchases, and on iOS getPurchases() returns false. Use the right call per platform:
public function restore():void { if ( store.isGetPurchasesSupported ) // Android { store.addEventListener( PurchaseEvent.GET_PURCHASES_COMPLETE, onOwnedPurchases ); store.addEventListener( PurchaseEvent.GET_PURCHASES_FAILED, onRestoreFailed ); store.getPurchases(); } else // iOS: STATE_RESTORED arrives in handlePurchase() { store.addEventListener( PurchaseEvent.RESTORE_PURCHASES_SUCCESS, onRestoreDone ); store.addEventListener( PurchaseEvent.RESTORE_PURCHASES_FAILED, onRestoreFailed ); store.restorePurchases(); } } private function onOwnedPurchases( e:PurchaseEvent ):void { for each ( var p:Purchase in e.data ) { // verify, unlock, finish: acknowledges a purchase that is still open, // and returns at once for one that is already acknowledged if ( p.transactionState == Purchase.STATE_PURCHASED ) handlePurchase( p ); } showMessage( "Purchases restored." ); } private function onRestoreDone( e:PurchaseEvent ):void { showMessage( "Purchases restored." ); } private function onRestoreFailed( e:PurchaseEvent ):void { showMessage( "Restoring failed. " + e.message ); }
Subscriptions do not have to be restored for your app to know about them: your server can check their state with the receipt or purchase token at any time. Restoring is for the user who reinstalled the app or uses a new device.
Pending purchases at startup
A purchase can be paid while your app is not running or before it could finish it: the app crashed after payment, the network was down during verification, an Ask to Buy request was approved, or (iOS) a subscription renewed or a purchase was started from your App Store page. KlugBilling hands these over after setup(), as ordinary PURCHASES_UPDATED events, so handlePurchase() from above covers them. That is why the listener must be added before setup().
| iOS | StoreKit delivers every unfinished transaction as soon as the observer is registered by setup(), and later transactions (renewals, approved Ask to Buy requests, purchases started from the App Store page) whenever they happen while the app runs. |
| Android | After SETUP_SUCCESS, KlugBilling asks Google Play for purchases that are paid but not yet acknowledged and delivers them with STATE_PURCHASED. Finish them within three days, or Google Play refunds them. |
getPendingPurchases() returns, without an event, the transactions that are still open. On iOS these are all unfinished transactions in the StoreKit queue except those still in STATE_PURCHASING; call it after setup(). On Android these are the purchases delivered in this session that are not yet acknowledged. Use it to avoid a second purchase while one is still open, not to deliver: the delivery runs through PURCHASES_UPDATED.
private function hasOpenTransaction( productId:String ):Boolean { for each ( var p:Purchase in store.getPendingPurchases() ) { if ( p.productId == productId ) return true; } return false; }
Verify purchases on your server
Before you unlock anything of value, let your server confirm the purchase with Apple or Google. Each Purchase carries what the server needs:
| iOS: transactionReceipt | The app receipt (base64), added to every PURCHASED and RESTORED purchase. It is the signed record of everything this Apple ID bought in your app. Your server validates it with Apple's verifyReceipt endpoint (production first, then the sandbox endpoint for sandbox receipts), which Apple has marked as deprecated; or it looks up transactionId with the App Store Server API. |
| iOS: transactionId, originalTransactionId | The App Store transaction ID, and the ID of the original purchase. For renewals and restored purchases originalTransactionId is the ID of the first purchase, so it identifies a subscription across all renewals. |
| Android: originalMessage, signature | The purchase data JSON exactly as Google Play signed it, and Google's base64 signature of it (SHA1withRSA). Your server checks the signature with your Play public key. transactionReceipt holds the same purchase data JSON. |
| Android: transactionId | The purchase token (also used as handle, and contained in originalMessage as purchaseToken). With it, package name and product ID your server asks the Google Play Developer API for the current state of the purchase or subscription. |
| Android: originalTransactionId | The Google Play order ID (such as GPA.1234-5678-9012-34567); empty while a purchase is pending. |
import flash.events.Event; import flash.events.IOErrorEvent; import flash.net.URLLoader; import flash.net.URLRequest; import flash.net.URLRequestMethod; // done( valid ) is called only when the server answered. Without an answer the purchase // stays unfinished and is delivered again after the next setup(). private function verifyOnServer( p:Purchase, done:Function ):void { var body:Object = { platform: store.platform, productId: p.productId, transactionId: p.transactionId }; if ( store.platform == "ios" ) { body.receipt = p.transactionReceipt; // app receipt, base64 body.originalTransactionId = p.originalTransactionId; } else { body.purchaseToken = p.transactionId; // for the Google Play Developer API body.purchaseData = p.originalMessage; // signed JSON body.signature = p.signature; } var request:URLRequest = new URLRequest( "https://api.example.com/verify-purchase" ); request.method = URLRequestMethod.POST; request.contentType = "application/json"; request.data = JSON.stringify( body ); var loader:URLLoader = new URLLoader(); loader.addEventListener( Event.COMPLETE, function( ev:Event ):void { var answer:Object = JSON.parse( String( loader.data ) ); done( answer.valid == true ); }); loader.addEventListener( IOErrorEvent.IO_ERROR, function( ev:IOErrorEvent ):void { hideSpinner(); // try again at the next start }); loader.load( request ); }
On iOS you can also read the app receipt at any time with applicationReceipt.getAppReceipt(), for example to send it to your server at login. It returns "" when there is no receipt on the device yet, which is normal for development builds before the first sandbox purchase. applicationReceipt.refresh() asks the App Store for a fresh one and may show an Apple ID sign-in; the result arrives as ApplicationReceiptEvent on the applicationReceipt object, not on the service. On Android applicationReceipt.isSupported is false.
public function sendReceiptToServer():void { var receipt:ApplicationReceipt = store.applicationReceipt; if ( !receipt.isSupported ) return; // iOS only var data:String = receipt.getAppReceipt(); if ( data != "" ) { uploadReceipt( data ); // your server call return; } receipt.addEventListener( ApplicationReceiptEvent.REFRESH_SUCCESS, function( e:ApplicationReceiptEvent ):void { uploadReceipt( receipt.getAppReceipt() ); }); receipt.addEventListener( ApplicationReceiptEvent.REFRESH_FAILED, function( e:ApplicationReceiptEvent ):void { trace( "receipt refresh failed: " + e.message ); }); receipt.refresh(); }
Testing
App Store. Test on a device; the iOS Simulator uses the fallback where isSupported is false.
- Create the products in App Store Connect with complete metadata, and make sure the Paid Applications agreement is active. Until then getProducts() reports your IDs as INVALID_PRODUCT.
- Development and ad-hoc builds buy in the App Store sandbox. Create a sandbox Apple Account in App Store Connect (Users and Access, Sandbox) and sign in with it when the purchase sheet asks, or in the sandbox account section of the device's settings. Nothing is charged.
- TestFlight builds also buy in the sandbox, with the tester's own Apple Account, free of charge.
- Sandbox subscriptions renew at an accelerated rate (minutes instead of months), so renewals and expiry can be tested in one session.
- Sandbox receipts must be verified against Apple's sandbox environment; production receipts against production.
Google Play. Test on a device or emulator with the Google Play Store and a signed-in Google account.
- Upload a build to a testing track (internal testing is fastest), and create and activate your products and subscriptions in the Play Console.
- Add the testers' Google accounts as license testers (Play Console, Settings, License testing) and to the testing track. Install the app from the track's opt-in link; the application ID of the build must match the app in the Play Console.
- License testers pay with test payment methods: a test card that always approves, one that always declines, and slow test cards that approve or decline after a few minutes. A slow card is the easiest way to test STATE_DEFERRED.
- Test subscriptions renew within minutes. Test purchases are not charged.
- KlugBilling writes warnings to logcat under the tag KlugBilling: adb logcat -s KlugBilling.
Error handling
Errors arrive in the errorCode of InAppBillingEvent, ProductEvent and PurchaseEvent, and in Purchase.errorCode of failed purchases, always as one of the ErrorCodes constants. message (or Purchase.errorMessage) adds the store's own text, which is meant for your logs, not for users.
| USER_CANCELLED "user_cancelled" | The user closed the purchase sheet (both). Show nothing. |
| ITEM_ALREADY_OWNED "item_already_owned" | Android: the Google account owns the product already. Call getPurchases() and deliver. |
| ITEM_UNAVAILABLE "item_unavailable" | The product is not available: unknown IDs in INVALID_PRODUCT (both), or not for sale at purchase time (both). |
| NOT_ALLOWED "not_allowed" | iOS: the device does not allow payments. |
| SERVICE_UNAVAILABLE "service_unavailable" | Android: Google Play is not reachable, disconnected, or billing is unavailable (for example no Google account in the Play Store). |
| NETWORK_ERROR "network_error" | A network problem. iOS: also every failed product request. |
| ERROR "error" | Any other error. On Android also a purchase with an invalid signature (message invalid purchase signature), a failed acknowledgement, and a failed getPurchases() or restorePurchases(). |
| RESPONSE_CANCELLED "response_cancelled" | Defined for completeness; not reported by version 1.0.0. |
API reference
All classes are in the package com.klug.billing; the events are in com.klug.billing.events.
InAppBilling
Static entry point. Everything is static; apps may extend the class, for example for a purchase helper.
| EXTENSION_ID | public static const EXTENSION_ID:String = "com.klug.billing" |
| VERSION | public static const VERSION:String = "1.0.0" Version of the ActionScript library. |
| isSupported | public static function get isSupported():Boolean True on iOS and Android devices, false elsewhere. Same as service.isSupported. |
| service | public static function get service():InAppBillingService The single store service; created on first access. |
| InAppBilling() | public function InAppBilling() Empty constructor, only for subclasses. |
InAppBillingService
Extends flash.events.EventDispatcher. Use it through InAppBilling.service; do not create further instances. All results are dispatched on this object, except ApplicationReceiptEvent.
| platform | public function get platform():String "ios", "android", or "" where the extension is not available. |
| isSupported | public function get isSupported():Boolean True where the native store is available (iOS, Android). |
| isServiceSupported() | public function isServiceSupported(type:String):Boolean True for APPLE_INAPP_PURCHASE on iOS and for GOOGLE_PLAY_INAPP_BILLING on Android; false otherwise and where unsupported. |
| canMakePayments | public function get canMakePayments():Boolean iOS: false when purchases are disabled on the device (e.g. Screen Time). Android: true while connected to Google Play. |
| isSetup | public function get isSetup():Boolean True after SETUP_SUCCESS; false before, after SETUP_FAILURE and after dispose(). |
| isGetPurchasesSupported | public function get isGetPurchasesSupported():Boolean True on Android, where getPurchases() lists owned purchases; false on iOS, where open transactions arrive through PURCHASES_UPDATED. |
| applicationReceipt | public function get applicationReceipt():ApplicationReceipt The iOS app receipt. |
| setup() | public function setup(service:BillingService = null):Boolean Connects to the store of the current platform; on Android passes the googlePlayPublicKey if set. Returns true if the request was started. Result: InAppBillingEvent.SETUP_SUCCESS or SETUP_FAILURE, then open transactions as PURCHASES_UPDATED. On iOS, returns false when a googlePlayPublicKey is set. |
| checkAvailability() | public function checkAvailability(callback:Function = null):void Calls callback(result:String) with one of InAppBillingAvailability and dispatches AvailabilityEvent.COMPLETE. Call it after SETUP_SUCCESS. |
| getProducts() | public function getProducts(productIds:Array, subscriptionIds:Array = null):Boolean Loads one-time products (productIds) and subscriptions (subscriptionIds), Arrays of String; empty entries are skipped. Returns false if both are empty or unsupported. Result: ProductEvent.PRODUCTS_LOADED (then INVALID_PRODUCT for unknown IDs) or PRODUCTS_FAILED. |
| getProduct() | public function getProduct(id:String):Product A product from an earlier PRODUCTS_LOADED, or null. |
| checkPurchaseRequestValid() | public function checkPurchaseRequestValid(request:PurchaseRequest):Boolean True when the request has a product ID, that product was loaded, and canMakePayments is true. |
| makePurchase() | public function makePurchase(request:PurchaseRequest):Boolean Opens the store's purchase sheet. Returns false if the product was not loaded, the request has no product ID, or (iOS) payments are not allowed. Progress and result: PURCHASES_UPDATED, plus PURCHASE_FAILED on failure or cancel. |
| finishPurchase() | public function finishPurchase(purchase:Purchase):Boolean Closes a transaction after delivery (Android: acknowledges it). Returns false for null, when unsupported, or for an unknown or already finished purchase. Result: PurchaseEvent.FINISH_SUCCESS or FINISH_FAILED. |
| getPendingPurchases() | public function getPendingPurchases():Array Array of Purchase still open, synchronously. iOS: unfinished transactions in the StoreKit queue, except those in STATE_PURCHASING. Android: purchases delivered in this session that are not yet acknowledged. |
| restorePurchases() | public function restorePurchases():Boolean iOS: restores the earlier purchases of the Apple ID as STATE_RESTORED in PURCHASES_UPDATED, then RESTORE_PURCHASES_SUCCESS or RESTORE_PURCHASES_FAILED (as PurchaseEvent and InAppBillingEvent). Android: reports only the success or failure events, without purchases; use getPurchases(). |
| getPurchases() | public function getPurchases():Boolean Android: lists the purchases of the Google account. Result: GET_PURCHASES_COMPLETE or GET_PURCHASES_FAILED. Returns false on iOS; no event follows there. |
| dispose() | public function dispose():void Disconnects from the store (iOS: removes the transaction observer). setup() can be called again later. |
BillingService
Describes the store for setup(). Builder style: the setters return the object.
| serviceType | public var serviceType:String = "" One of InAppBillingServiceTypes. |
| googlePlayPublicKey | public var googlePlayPublicKey:String = "" Android: the base64 RSA public key from the Play Console. Purchases are signature-checked with it. |
| BillingService() | public function BillingService() |
| setServiceType() | public function setServiceType(type:String):BillingService |
| setGooglePlayPublicKey() | public function setGooglePlayPublicKey(key:String):BillingService Sets the key (null becomes ""). If no service type is set yet, sets GOOGLE_PLAY_INAPP_BILLING. |
InAppBillingServiceTypes
| APPLE_INAPP_PURCHASE | public static const APPLE_INAPP_PURCHASE:String = "apple_inapp_purchase" The App Store (iOS). |
| GOOGLE_PLAY_INAPP_BILLING | public static const GOOGLE_PLAY_INAPP_BILLING:String = "google_play_inapp_billing" Google Play (Android). |
InAppBillingAvailability
| AVAILABLE | public static const AVAILABLE:String = "available" |
| NOT_AVAILABLE | public static const NOT_AVAILABLE:String = "not_available" |
| STORE_UPGRADE_REQUIRED | public static const STORE_UPGRADE_REQUIRED:String = "store_upgrade_required" Android: the Play Store on the device is too old for subscriptions. |
Product
A product as the store reports it, with localized texts and prices.
| TYPE_INAPP | public static const TYPE_INAPP:String = "inapp" One-time product. |
| TYPE_PRODUCT | public static const TYPE_PRODUCT:String = "inapp" Same value as TYPE_INAPP. |
| TYPE_SUBSCRIPTION | public static const TYPE_SUBSCRIPTION:String = "subscription" Auto-renewing subscription. |
| id | public var id:String The product ID. |
| type | public var type:String TYPE_INAPP or TYPE_SUBSCRIPTION. |
| title | public var title:String Localized title. Android: with the app name in brackets. |
| description | public var description:String Localized description. |
| price | public var price:Number The price as a number, e.g. 19.99. Subscriptions: the regular price. |
| priceString | public var priceString:String The price formatted by the store, with currency. |
| currencyCode | public var currencyCode:String ISO 4217 code, e.g. "EUR". |
| subscriptionPeriod | public var subscriptionPeriod:SubscriptionPeriod Subscriptions: the regular billing period. null for one-time products. |
| subscriptionOffers | public var subscriptionOffers:Array Subscriptions: Array of SubscriptionOffer; [0] is the regular price. null for one-time products. |
| fromObject() | public static function fromObject(o:Object):Product Used by the extension to build products from the native data. |
| toString() | public function toString():String E.g. [Product id=com.example.pro.yearly type=subscription price=19,99 € period=1 year]. |
Purchase
A store transaction. Finish it with finishPurchase() once delivered.
| STATE_PURCHASING | public static const STATE_PURCHASING:String = "transaction:purchasing" iOS: the purchase sheet is open. |
| STATE_PURCHASED | public static const STATE_PURCHASED:String = "transaction:purchased" Paid (both). |
| STATE_FAILED | public static const STATE_FAILED:String = "transaction:failed" Failed (both). |
| STATE_CANCELLED | public static const STATE_CANCELLED:String = "transaction:cancelled" Cancelled by the user (both). |
| STATE_RESTORED | public static const STATE_RESTORED:String = "transaction:restored" iOS: restored by restorePurchases(). |
| STATE_DEFERRED | public static const STATE_DEFERRED:String = "transaction:deferred" Waiting: Ask to Buy (iOS), pending payment (Android). |
| STATE_REFUNDED | public static const STATE_REFUNDED:String = "transaction:refunded" Defined; not reported by version 1.0.0. Refunds are visible to your server. |
| STATE_REMOVED | public static const STATE_REMOVED:String = "transaction:removed" Defined; not reported by version 1.0.0. |
| STATE_NOTALLOWED | public static const STATE_NOTALLOWED:String = "transaction:notallowed" Defined; not reported by version 1.0.0 (iOS reports STATE_FAILED with NOT_ALLOWED). |
| STATE_UNKNOWN | public static const STATE_UNKNOWN:String = "transaction:unknown" No defined state (Android). |
| productId | public var productId:String |
| transactionId | public var transactionId:String iOS: the App Store transaction ID (empty while purchasing, failed or cancelled). Android: the purchase token; empty for failed and cancelled purchases. |
| originalTransactionId | public var originalTransactionId:String iOS: the transaction ID of the original purchase (renewals, restores), otherwise the own ID. Android: the Google Play order ID. |
| transactionDate | public var transactionDate:Number Milliseconds since 1970; use new Date(p.transactionDate). |
| transactionState | public var transactionState:String One of the STATE_* constants. |
| transactionReceipt | public var transactionReceipt:String iOS: the app receipt (base64) for PURCHASED and RESTORED, otherwise empty. Android: the purchase data JSON (same as originalMessage). |
| originalMessage | public var originalMessage:String Android: the purchase data JSON as signed by Google. Empty on iOS. |
| signature | public var signature:String Android: Google's base64 signature of originalMessage. Empty on iOS. |
| quantity | public var quantity:int = 1 |
| errorCode | public var errorCode:String For failed and cancelled purchases: one of ErrorCodes. |
| errorMessage | public var errorMessage:String The store's error text. |
| handle | public var handle:String Internal handle used by finishPurchase(). Do not change it. |
| fromObject() | public static function fromObject(o:Object):Purchase Used by the extension to build purchases from the native data. |
| toString() | public function toString():String E.g. [Purchase productId=com.example.removeads transactionId=… state=transaction:purchased]. |
PurchaseRequest
What to buy. Builder style.
| productId | public var productId:String = "" |
| quantity | public var quantity:int = 1 iOS: the quantity of the payment (at least 1; for consumable products). Android: not used; Google Play asks for the quantity itself when you enable multi-quantity purchases for the product. |
| subscriptionOfferRequest | public var subscriptionOfferRequest:SubscriptionOfferRequest Optional: the subscription offer to buy. |
| PurchaseRequest() | public function PurchaseRequest() |
| setProductId() | public function setProductId(id:String):PurchaseRequest |
| setQuantity() | public function setQuantity(q:int):PurchaseRequest |
| setSubscriptionOfferRequest() | public function setSubscriptionOfferRequest(r:SubscriptionOfferRequest):PurchaseRequest |
SubscriptionOffer
A way to buy a subscription. Product.subscriptionOffers[0] is always the regular price.
| id | public var id:String = "" iOS: "" (regular) or "introductory". Android: the offer ID, or the base plan ID for a base plan. |
| tags | public var tags:Array = [] Array of String. iOS: "free-trial" or "introductory" on the introductory offer. Android: the offer tags from the Play Console. |
| pricingPhases | public var pricingPhases:Array = [] Array of SubscriptionPhase, in the order the customer goes through them. |
| offerToken | public var offerToken:String = "" Android: Google's offer token for the purchase. Empty on iOS. |
| fromObject() | public static function fromObject(o:Object):SubscriptionOffer Used by the extension. |
SubscriptionOfferRequest
Chooses the offer for a subscription purchase. Builder style.
| subscriptionOffer | public var subscriptionOffer:SubscriptionOffer |
| SubscriptionOfferRequest() | public function SubscriptionOfferRequest() |
| setSubscriptionOffer() | public function setSubscriptionOffer(offer:SubscriptionOffer):SubscriptionOfferRequest |
SubscriptionPeriod
Length of a subscription or pricing phase, e.g. 3 x UNIT_MONTH.
| UNIT_DAY | public static const UNIT_DAY:String = "day" |
| UNIT_WEEK | public static const UNIT_WEEK:String = "week" |
| UNIT_MONTH | public static const UNIT_MONTH:String = "month" |
| UNIT_YEAR | public static const UNIT_YEAR:String = "year" |
| unit | public var unit:String = "" One of the UNIT_* constants. |
| numberOfUnits | public var numberOfUnits:int = 0 |
| fromObject() | public static function fromObject(o:Object):SubscriptionPeriod Used by the extension; returns null for null. |
| toString() | public function toString():String E.g. "1 year", "3 month". |
SubscriptionPhase
One pricing phase of a subscription offer.
| INFINITE_RECURRING | public static const INFINITE_RECURRING:int = 1 Repeats until cancelled (the regular price). |
| FINITE_RECURRING | public static const FINITE_RECURRING:int = 2 Repeats numberOfPeriods times. |
| NON_RECURRING | public static const NON_RECURRING:int = 3 Charged once (Android). |
| price | public var price:Number = 0 |
| priceString | public var priceString:String = "" |
| currencyCode | public var currencyCode:String = "" |
| numberOfPeriods | public var numberOfPeriods:int = 0 0 for INFINITE_RECURRING. |
| recurrenceMode | public var recurrenceMode:int = INFINITE_RECURRING |
| subscriptionPeriod | public var subscriptionPeriod:SubscriptionPeriod Length of one billing period of this phase. |
| fromObject() | public static function fromObject(o:Object):SubscriptionPhase Used by the extension. |
ApplicationReceipt
Extends flash.events.EventDispatcher. The iOS app receipt, the signed record of everything this Apple ID bought in the app. Get it through InAppBilling.service.applicationReceipt.
| isSupported | public function get isSupported():Boolean True on iOS, false on Android and elsewhere. |
| getAppReceipt() | public function getAppReceipt():String The app receipt as base64, or "" if there is none on the device yet (call refresh()). |
| refresh() | public function refresh():Boolean Asks the App Store for a fresh receipt; may show an Apple ID sign-in. Returns false on Android. Result on this object: ApplicationReceiptEvent.REFRESH_SUCCESS or REFRESH_FAILED. |
ErrorCodes
| ERROR | public static const ERROR:String = "error" |
| USER_CANCELLED | public static const USER_CANCELLED:String = "user_cancelled" |
| RESPONSE_CANCELLED | public static const RESPONSE_CANCELLED:String = "response_cancelled" Not reported by version 1.0.0. |
| ITEM_ALREADY_OWNED | public static const ITEM_ALREADY_OWNED:String = "item_already_owned" |
| ITEM_UNAVAILABLE | public static const ITEM_UNAVAILABLE:String = "item_unavailable" |
| NOT_ALLOWED | public static const NOT_ALLOWED:String = "not_allowed" |
| SERVICE_UNAVAILABLE | public static const SERVICE_UNAVAILABLE:String = "service_unavailable" |
| NETWORK_ERROR | public static const NETWORK_ERROR:String = "network_error" |
When each code occurs is described in Error handling.
Events
All event classes are in com.klug.billing.events, extend flash.events.Event, do not bubble and cannot be cancelled. They are dispatched on InAppBilling.service, except ApplicationReceiptEvent, which is dispatched on InAppBilling.service.applicationReceipt. Each class implements clone().
InAppBillingEvent
InAppBillingEvent(type:String, errorCode:String = "", message:String = "", data:Array = null). Fields: errorCode:String, message:String, data:Array (not filled in 1.0.0).
| SETUP_SUCCESS "inappbilling:setup:success" | Connected to the store. Open transactions follow as PURCHASES_UPDATED. |
| SETUP_FAILURE "inappbilling:setup:failure" | Android: Google Play is not available. errorCode (e.g. service_unavailable) and message say why. |
| RESTORE_PURCHASES_SUCCESS "inappbilling:restore:success" | Dispatched together with PurchaseEvent.RESTORE_PURCHASES_SUCCESS. |
| RESTORE_PURCHASES_FAILED "inappbilling:restore:failed" | Dispatched together with PurchaseEvent.RESTORE_PURCHASES_FAILED; errorCode, message. |
| CONSUME_SUCCESS "inappbilling:consume:success" | Defined; not dispatched by version 1.0.0. |
| CONSUME_FAILED "inappbilling:consume:failed" | Defined; not dispatched by version 1.0.0. |
ProductEvent
ProductEvent(type:String, products:Array = null, data:Array = null, errorCode:String = "", message:String = ""). Fields: products:Array (of Product), data:Array (of product ID strings), errorCode:String, message:String.
| PRODUCTS_LOADED "product:loaded" | products: the products the store knows (may be empty). |
| INVALID_PRODUCT "product:invalid" | After PRODUCTS_LOADED, when IDs are unknown. data: the unknown IDs; errorCode: item_unavailable; message: unknown product id. |
| PRODUCTS_FAILED "product:failed" | The request failed. errorCode (iOS: network_error), message. |
PurchaseEvent
PurchaseEvent(type:String, data:Array = null, errorCode:String = "", message:String = ""). Fields: data:Array (of Purchase), errorCode:String, message:String.
| PURCHASES_UPDATED "purchases:updated" | Every state change of every transaction, including open transactions after setup() and restored ones. data: the purchases. |
| PURCHASE_FAILED "purchase:failed" | After a failed or cancelled purchase. data: the purchase (empty for Android's item_already_owned); errorCode, message. |
| FINISH_SUCCESS "purchase:finish:success" | finishPurchase() succeeded. data: the purchase. |
| FINISH_FAILED "purchase:finish:failed" | Unknown or already finished purchase, or (Android) the acknowledgement failed. data: the purchase if known; errorCode: error; message. |
| RESTORE_PURCHASES_SUCCESS "purchase:restore:success" | restorePurchases() completed. data: null; the restored purchases arrived in PURCHASES_UPDATED (iOS). |
| RESTORE_PURCHASES_FAILED "purchase:restore:failed" | restorePurchases() failed. errorCode, message. |
| GET_PURCHASES_COMPLETE "purchase:get:complete" | Android: getPurchases() succeeded. data: the owned purchases. |
| GET_PURCHASES_FAILED "purchase:get:failed" | Android: Google Play could not be asked completely. errorCode: error; data may hold the purchases that were found. |
AvailabilityEvent
AvailabilityEvent(type:String, availability:String = ""). Field: availability:String, one of InAppBillingAvailability.
| COMPLETE "availability:complete" | Result of checkAvailability(). |
ApplicationReceiptEvent
ApplicationReceiptEvent(type:String, message:String = ""). Field: message:String. Dispatched on applicationReceipt.
| REFRESH_SUCCESS "applicationreceipt:refresh:success" | A fresh receipt is on the device; read it with getAppReceipt(). |
| REFRESH_FAILED "applicationreceipt:refresh:failed" | The refresh failed or the user cancelled the sign-in. message: the reason. |
Troubleshooting
isSupported is false on iOS
The native part is built for iOS devices. In the iOS Simulator the fallback is used. Test on a device.
setup() returns false on iOS and no setup event arrives
The BillingService carries a googlePlayPublicKey. Set the key only in the Android branch, as in Set up the store.
SETUP_FAILURE on Android
Google Play is not available on the device: no Play Store, no Google account signed in to it, or an outdated Play Store. Emulators need a system image with Google Play. errorCode and message carry Google's reason.
PRODUCTS_LOADED is empty, or my IDs come back in INVALID_PRODUCT
Check that the IDs are exactly the ones in App Store Connect or the Play Console. iOS: the products need complete metadata and an active Paid Applications agreement. Android: subscriptions belong in the second array of getProducts(), one-time products in the first; the products must be active, and the app must be uploaded to a testing track and installed by a tester account.
makePurchase() returns false
The product was not loaded with getProducts() in this session, or (iOS) the device does not allow payments. Use checkPurchaseRequestValid() first.
checkPurchaseRequestValid() is false on Android after a long time in the background
The connection to Google Play was lost, so canMakePayments is false. Call setup() again and buy after SETUP_SUCCESS.
The same purchase arrives at every app start
It was never finished. Call finishPurchase() after delivery, also for failed and cancelled transactions, and watch for FINISH_FAILED.
Google Play refunded purchases after three days
The purchases were not acknowledged. On Android finishPurchase() is the acknowledgement. Keep the PURCHASES_UPDATED listener in place before setup() so that purchases replayed at start are finished as well.
ITEM_ALREADY_OWNED on Android
The Google account owns the product. Call getPurchases() and deliver what it lists. Version 1.0.0 does not consume one-time products on Google Play, so they cannot be bought a second time.
Every Android purchase fails with "invalid purchase signature"
The public key does not belong to this app. Copy it again from the Play Console of exactly this app (Monetization setup). A key that cannot be read at all is skipped with a logcat warning under the tag KlugBilling.
FINISH_FAILED with "unknown or already finished transaction"
The purchase was finished before, it is still PURCHASING or DEFERRED, or the Purchase object is from an earlier app session. Finish each purchase once, with the object from the current session's events.
restorePurchases() delivers nothing on Android
On Android it only reports success or failure. Use getPurchases(); isGetPurchasesSupported tells you which call to use.
getAppReceipt() returns an empty string
Development builds have no receipt until the first sandbox purchase. Call applicationReceipt.refresh() and read it again after REFRESH_SUCCESS.
iOS shows the introductory offer to a user who already used it
The App Store reports the introductory offer without eligibility, and StoreKit charges the regular price when the user is not eligible. If you want to hide the offer, decide on your server from the receipt whether the user had a trial before. Google Play returns only offers the user is eligible for.
The product title on Android contains my app name
Google Play adds the app name in brackets to product titles. Show your own label, or cut the part in brackets.
The quantity is ignored on Android
PurchaseRequest.quantity is used on iOS only. On Google Play the user picks the quantity in the purchase sheet when multi-quantity purchases are enabled for the product; Purchase.quantity reports it.
The Android build fails to resolve the billing library
The Google Play Billing Library is fetched through Gradle when ADT packages the app. Make sure the build machine can reach Google's Maven repository, or that the library is in the local Gradle cache.
Changelog
1.0.0
- First release for iOS (StoreKit) and Android (Google Play Billing Library 8.3.0, added by Gradle at packaging)
- One-time products and auto-renewing subscriptions with localized prices, billing periods, offers and pricing phases
- All transaction states as events; finishPurchase() finishes iOS transactions and acknowledges Google Play purchases
- Open transactions and unacknowledged Google Play purchases delivered again after setup(); purchases started from the App Store page accepted
- Restore on iOS, owned purchases on Android, the iOS app receipt with refresh
- Android: signature check of every purchase against the Play public key