📜 ⬆️ ⬇️

iTunes In-App Purchases from server side

ITunes Payments are actual leaders in monetizing content provided by mobile apps. In one of the applications I know, the income from them is 3 times higher than the income from Google Play users, while the attendance of the latter is 1.5 times higher. Thus, from one iTunes user you can receive up to 5 times more money than from one Google Play user. This argument is sufficient to integrate iTunes payments into mobile applications.

This article describes some features of the verification of iTunes payments (including subscriptions) from the server side, which, it seemed to me, are not sufficiently covered in existing articles.


')
In accordance with the developer’s guide , two payment transaction verification schemes are proposed: simple, in which the confirmation of a transaction occurs as a result of the interaction of a mobile application and the App Store, and complex. In the second case, an additional confirmation step is introduced from your own server by contacting iTunes Connect. The fact of successful confirmation of a payment transaction through iTunes Connect is considered sufficient to verify the payment.
The downside to simple verification is a broken trust . The advantages of complex include the convenience of working with subscriptions, the possibility of charging worldly goods and storing the list of products on the server side. The last two points are especially relevant when you have to wait for the app update in the App Store for a week. And maybe a few weeks , if you suddenly decide to please users seductive product in anticipation of another Christmas. I don’t even talk about security - everything is quite clear in the following graph:



So in the monitoring system of payment requests for an abstract application, it may look like quite ordinary days. The blue color represents the total number of requests for payment verification. Green - requests that actually went through the App Store. And red - malicious requests. It is terrible to imagine what loss of profits the application can receive if it ignores the server payment confirmation. The estimated ratio of the data from the graph is presented in the following table:

Request featurePercent
Unconfirmed. Fake payments, consisting of data similar to the correct ones, but perhaps there is no field in them, the number is represented by a line or is there any other distinctive feature that does not allow to verify the payment0.7%
Repetitions. Requests from the client with a verified payment, but sent again after a whileone%
Cracker payments (type, iAP Cracker , etc.). They send for verification payments formulated for confirmation by themselves.9.3%
Fake. ITunes-verified payments from other apps79%
Confirmed. Really honest purchases. Their numbers converge with the numbers of purchases through the accountten%


In fact, most malicious requests can be determined on their own without spending any traffic on accessing the verification service. ITunes payment appears so-called. recipe . A recipe is a base64 JSON-encoded payment transaction data object. To verify a payment or subscription through the App Store service, you need to submit their recipe, which is reported by the client application. In response, get the status of the recipe and some payment details

Consider the correct recipe (hereinafter, the data of the correct recipes are slightly modified):

$ php -r "var_dump(base64_decode('Re4LRece1PT='));" string(2453) "{ "signature" = "8iN4rY5iGNaTUrE=="; "purchase-info" = "PuRCh45e1nf0RM4tIoN=="; "pod" = "22"; "signing-status" = "0"; }" $ php -r "var_dump(base64_decode('PuRCh45e1nf0RM4tIoN=='));" string(784) "{ "original-purchase-date-pst" = "2013-02-18 10:05:51 America/Los_Angeles"; "purchase-date-ms" = "1361210751012"; "unique-identifier" = "aun1que1dent1f1er"; "original-transaction-id" = "1234567890"; "bvrs" = "220"; "app-item-id" = "123"; "transaction-id" = "1234567890"; "quantity" = "1"; "original-purchase-date-ms" = "1361210751012"; "unique-vendor-identifier" = "VEND0R-1DENT1F1ER"; "item-id" = "456"; "version-external-identifier" = "789"; "product-id" = "com.example.application.product.1"; "purchase-date" = "2013-02-18 18:05:51 Etc/GMT"; "original-purchase-date" = "2013-02-18 18:05:51 Etc/GMT"; "bid" = "com.example.application"; "purchase-date-pst" = "2013-02-18 10:05:51 America/Los_Angeles"; }" 


A recipe consists of purchase data, a signature, and a pair of service fields. The signature is binary and base64 encoded. These purchases are also encoded and represent a JSON object with multiple fields. I consider two fields to be the most interesting: product-id is the identifier of the purchased product and bid is the identifier of the application.

Top picks of malicious requests — fake requests — look like this:

 $ php -r "var_dump(base64_decode('CHuZH0iRECE1pt=='));" string(2281) "{ "signature" = "8iN4rY5iGNaTUrE=="; "purchase-info" = "4n0THeRPuRCh45e1nf0RM4tIoN=="; "pod" = "17"; "signing-status" = "0"; }" $ php -r "var_dump(base64_decode('4n0THeRPuRCh45e1nf0RM4tIoN=='));" string(656) "{ "original-purchase-date-pst" = "2012-07-12 05:54:35 America/Los_Angeles"; "purchase-date-ms" = "1342097675882"; "original-transaction-id" = "170000029449420"; "bvrs" = "1.4"; "app-item-id" = "450542233"; "transaction-id" = "170000029449420"; "quantity" = "1"; "original-purchase-date-ms" = "1342097675882"; "item-id" = "534185042"; "version-external-identifier" = "9051236"; "product-id" = "com.zeptolab.ctrbonus.superpower1"; "purchase-date" = "2012-07-12 12:54:35 Etc/GMT"; "original-purchase-date" = "2012-07-12 12:54:35 Etc/GMT"; "bid" = "com.zeptolab.ctrexperiments"; "purchase-date-pst" = "2012-07-12 05:54:35 America/Los_Angeles"; }" 


It is a decent recipe. Only not from our application. If you apply to iTunes Connect, you will receive a confirmation of this payment:

 $ wget 'https://buy.itunes.apple.com/verifyReceipt' -q --post-data='{"receipt-data":"CHuZH0iRECE1pt=="}' -O - {"receipt":{"original_purchase_date_pst":"2012-07-12 05:54:35 America/Los_Angeles", "purchase_date_ms":"1342097675882", "original_transaction_id":"170000029449420", "original_purchase_date_ms":"1342097675882", "app_item_id":"450542233", "transaction_id":"170000029449420", "quantity":"1", "bvrs":"1.4", "version_external_identifier":"9051236", "bid":"com.zeptolab.ctrexperiments", "product_id":"com.zeptolab.ctrbonus.superpower1", "purchase_date":"2012-07-12 12:54:35 Etc/GMT", "purchase_date_pst":"2012-07-12 05:54:35 America/Los_Angeles", "original_purchase_d 


In principle, could not check. You can save 80% of the traffic to iTunes by comparing product-id and bid with those allowed in our application at the stage of receiving a recipe from a client application.

The recipes created by crackers are rather primitive: Y29tLnVydXMuaWFwLjk2NjU3Mjkw . Decrypt, we get com.urus.iap.96657290 . Obviously, there is not even a talk about any recipe structure - neither a signature nor a purchase data. Such recipes can be safely rejected. iTunes for such a recipe will return error 21002 .

If, in the case of receiving recipes created by crackers, you can verify the recipe both on your own and with the help of the iTunes service, then duplicates can be found only on your side. It is enough to keep all transaction identifiers for which benefits were accrued, and to check whether such an identifier was already among those that have passed. According to the documentation, all transaction identifiers uniquely identify the payment.

The smallest evil from the sample is unconfirmed recipes. Below is an example of one:
 $ php -r "var_dump(base64_decode('P0dDe1NyRECE1pt=='));" string(613) "{"signing-status"="0";"purchase-info"="P0dDe1N0e1NF0==";"pid"="143";"signature"="1POdP1sD4jEe5t=";}" $ php -r "var_dump(base64_decode('P0dDe1N0e1NF0=='));" string(388) "{"unique-identifier"="an0theru1que1dent1f1er";"purchase-date"="2012-02-18 19:23:27 Etc/GMT";"original-transaction-id"="0123456789";"quantity"="1";"original-purchase-date"="2012-02-18 19:23:27 Etc/GMT";"bvrs"="123";"product-id"="com.example.application.product.1";"item-id"="456";"transaction-id"="0123456789";"bid"="com.example.application";}" 


Compared with the correct recipe, in this case, the savings on space, but this is not a reason to reject the recipe - because it is compiled and encoded by the client application. And so the recipe looks correct: there are correct product and application identifiers, plausible payment data, a signature. You need to send a request to iTunes (well, that such requests are only 0.7% of the total number and 7% of the number of useful requests). iTunes will respond with the code 21002.

The picture below shows the verification algorithm for recipes received from the client application on its own server side:



Directly for verification through iTunes, I propose to use a small and convenient library . This library allows you to verify including updated subscriptions . You can request information on the subscription with the same verification request, specifying the secret password when the client is initialized.

 $AppStore = new \AppStore\Client\AppStoreClient(); $AppStore->setPassword('secret shared password') ->setSandbox((bool) mt_rand(0,1)); $Status = $AppStore->verifyReceipt('5t4TUs=='); 


iTunes will return the response data to us as follows.

 object(AppStore\Client\Response\RenewableStatus)#7 (4) { ["latestReceipt":"AppStore\Client\Response\RenewableStatus":private]=> string(3460) "5t4TUs==" ["LatestReceiptInfo":"AppStore\Client\Response\RenewableStatus":private]=> object(AppStore\Client\Response\RenewableReceipt)#8 (11) { ["expiresDate":"AppStore\Client\Response\RenewableReceipt":private]=> string(13) "1363547483000" ["quantity":"AppStore\Client\Response\Receipt":private]=> int(1) ["productId":"AppStore\Client\Response\Receipt":private]=> string(35) "com.example.application.product.2" ["transactionId":"AppStore\Client\Response\Receipt":private]=> string(15) "0987654321" ["purchaseDate":"AppStore\Client\Response\Receipt":private]=> string(27) "2013-02-18 20:11:23 Etc/GMT" ["originalTransactionId":"AppStore\Client\Response\Receipt":private]=> string(15) "9078563412" ["originalPurchaseDate":"AppStore\Client\Response\Receipt":private]=> string(27) "2013-01-18 20:11:25 Etc/GMT" ["appItemId":"AppStore\Client\Response\Receipt":private]=> string(9) "456" ["versionExternalIdentifier":"AppStore\Client\Response\Receipt":private]=> string(0) "" ["bid":"AppStore\Client\Response\Receipt":private]=> string(19) "com.example.application" ["bvrs":"AppStore\Client\Response\Receipt":private]=> string(3) "123" } ["status":"AppStore\Client\Response\Status":private]=> int(0) ["Receipt":"AppStore\Client\Response\Status":private]=> object(AppStore\Client\Response\RenewableReceipt)#9 (11) { ["expiresDate":"AppStore\Client\Response\RenewableReceipt":private]=> string(13) "1363547483000" ["quantity":"AppStore\Client\Response\Receipt":private]=> int(1) ["productId":"AppStore\Client\Response\Receipt":private]=> string(35) "com.example.application.product.2" ["transactionId":"AppStore\Client\Response\Receipt":private]=> string(15) "0987654321" ["purchaseDate":"AppStore\Client\Response\Receipt":private]=> string(27) "2013-02-18 20:11:23 Etc/GMT" ["originalTransactionId":"AppStore\Client\Response\Receipt":private]=> string(15) "9078563412" ["originalPurchaseDate":"AppStore\Client\Response\Receipt":private]=> string(27) "2013-01-18 20:11:25 Etc/GMT" ["appItemId":"AppStore\Client\Response\Receipt":private]=> string(9) "456" ["versionExternalIdentifier":"AppStore\Client\Response\Receipt":private]=> string(0) "" ["bid":"AppStore\Client\Response\Receipt":private]=> string(19) "com.example.application" ["bvrs":"AppStore\Client\Response\Receipt":private]=> string(3) "123" } } 


Unlike Google Play subscriptions , iTunes creates a new subscription recipe for each payment period. About a day before the start of the next payment period, iTunes is trying to withdraw money from the user's account, although I saw a complaint that the money for renewing a subscription was written off 48 hours before the start of the new payment period. If the attempt has not yet been carried out and has not yet successfully passed the data presented in the latest_receipt match the data of the original recipe, as in the example above. If the subscription is successfully renewed, the automatic purchase data will be presented in the latest_receipt_info field, the encoded recipe in the latest_receipt field

 object(AppStore\Client\Response\RenewableStatus)#7 (4) { ["latestReceipt":"AppStore\Client\Response\RenewableStatus":private]=> string(3460) "ReNEW481E5t4TUs==" ["LatestReceiptInfo":"AppStore\Client\Response\RenewableStatus":private]=> object(AppStore\Client\Response\RenewableReceipt)#8 (11) { ["expiresDate":"AppStore\Client\Response\RenewableReceipt":private]=> string(13) "1363547483000" ["quantity":"AppStore\Client\Response\Receipt":private]=> int(1) ["productId":"AppStore\Client\Response\Receipt":private]=> string(35) "com.example.application.product.2" ["transactionId":"AppStore\Client\Response\Receipt":private]=> string(15) "0987654321" ["purchaseDate":"AppStore\Client\Response\Receipt":private]=> string(27) "2013-02-18 20:11:23 Etc/GMT" ["originalTransactionId":"AppStore\Client\Response\Receipt":private]=> string(15) "9078563412" ["originalPurchaseDate":"AppStore\Client\Response\Receipt":private]=> string(27) "2013-01-18 20:11:25 Etc/GMT" ["appItemId":"AppStore\Client\Response\Receipt":private]=> string(9) "456" ["versionExternalIdentifier":"AppStore\Client\Response\Receipt":private]=> string(0) "" ["bid":"AppStore\Client\Response\Receipt":private]=> string(19) "com.example.application" ["bvrs":"AppStore\Client\Response\Receipt":private]=> string(3) "123" } ["status":"AppStore\Client\Response\Status":private]=> int(0) ["Receipt":"AppStore\Client\Response\Status":private]=> object(AppStore\Client\Response\RenewableReceipt)#9 (11) { ["expiresDate":"AppStore\Client\Response\RenewableReceipt":private]=> string(13) "1361131883894" ["quantity":"AppStore\Client\Response\Receipt":private]=> int(1) ["productId":"AppStore\Client\Response\Receipt":private]=> string(35) "com.example.application.product.2" ["transactionId":"AppStore\Client\Response\Receipt":private]=> string(15) "0987654312" ["purchaseDate":"AppStore\Client\Response\Receipt":private]=> string(27) "2013-01-18 20:11:23 Etc/GMT" ["originalTransactionId":"AppStore\Client\Response\Receipt":private]=> string(15) "9078563412" ["originalPurchaseDate":"AppStore\Client\Response\Receipt":private]=> string(27) "2013-01-18 20:11:25 Etc/GMT" ["appItemId":"AppStore\Client\Response\Receipt":private]=> string(9) "456" ["versionExternalIdentifier":"AppStore\Client\Response\Receipt":private]=> string(0) "" ["bid":"AppStore\Client\Response\Receipt":private]=> string(19) "com.example.application" ["bvrs":"AppStore\Client\Response\Receipt":private]=> string(3) "123" } } 


In case the subscription cannot be renewed, the response status is returned 21006

 $AppStore = new \AppStore\Client\AppStoreClient(); $AppStore->setPassword('secret shared password') ->setSandbox((bool) mt_rand(0,1)); try { $Status = $AppStore->verifyReceipt('ExP1ReD5t4TUs=='); } catch (\AppStore\Client\Response\ExpiredSubscriptionException $ex) { var_dump($ex->getStatus()); } 


 object(AppStore\Client\Response\RenewableStatus)#7 (4) { ["latestReceipt":"AppStore\Client\Response\RenewableStatus":private]=> string(0) "" ["LatestReceiptInfo":"AppStore\Client\Response\RenewableStatus":private]=> NULL ["status":"AppStore\Client\Response\Status":private]=> int(21006) ["Receipt":"AppStore\Client\Response\Status":private]=> object(AppStore\Client\Response\RenewableReceipt)#8 (11) { ["expiresDate":"AppStore\Client\Response\RenewableReceipt":private]=> string(13) "1361208738953" ["quantity":"AppStore\Client\Response\Receipt":private]=> int(1) ["productId":"AppStore\Client\Response\Receipt":private]=> string(35) "com.example.application.product.2" ["transactionId":"AppStore\Client\Response\Receipt":private]=> string(15) "2143658709" ["purchaseDate":"AppStore\Client\Response\Receipt":private]=> string(27) "2013-01-18 17:32:18 Etc/GMT" ["originalTransactionId":"AppStore\Client\Response\Receipt":private]=> string(15) "2143658709" ["originalPurchaseDate":"AppStore\Client\Response\Receipt":private]=> string(27) "2013-01-18 17:32:19 Etc/GMT" ["appItemId":"AppStore\Client\Response\Receipt":private]=> string(9) "456" ["versionExternalIdentifier":"AppStore\Client\Response\Receipt":private]=> string(0) "" ["bid":"AppStore\Client\Response\Receipt":private]=> string(19) "com.example.application" ["bvrs":"AppStore\Client\Response\Receipt":private]=> string(3) "123" } } 


I suggest the following scheme for processing iTunes subscriptions on the server side:



Description:



According to my data, ~ 60% of iTunes subscriptions are renewed. For Google Play subscriptions, this value is ~ 40%. And the overwhelming majority of cases of the inability to renew a subscription are cases of lack of funds in user accounts.

Source: https://habr.com/ru/post/162335/


All Articles