Any (early) experiences with auto-renewable subscriptions for iOS

Kai Huppmann picture Kai Huppmann · Feb 16, 2011 · Viewed 14.2k times · Source

Apple finally introduced the so called auto-renewable subscriptions yesterday. Since I only have few (sandbox-only) experiences with in-app-purchase, I'm not sure that I got it all right here. It seems one needs a server side verification of the receipts. It seems the only way to find out if the subscription is still valid, is to store the original transaction data on server side. Apples programming guide with respect to this topic is all cryptic to me.

My expectation was, that I can work with an iOS client only, by just asking iTunes via store kit api did he/she already buy this (subscription-)product and receiving a yes/no answer together with an expiration date.

Does anyone have experiences with auto-renewable subscriptions or (because they seem somehow similar) non-consumable products? Are there any good tutorials about this?

Thank you.

Answer

manitu picture manitu · Jun 9, 2011

I have it running in the sandbox, almost going live...

One should use a server to verify the receipts.

On the server you can record the device udid with the receipt data, since receipts are always freshly generated, and it will work across multiple devices, since the receipts are always freshly generated.

On the device one does not need to store any sensitive data, and should not :)

One should check the last receipt with the store whenever the app comes up. The app calls the server and the server validates with the store. As long as the store returns a valid receipt app serves the feature.

I developed a Rails3.x app to handle the server side, the actual code for the verification looks like this:

APPLE_SHARED_PASS = "enter_yours"
APPLE_RECEIPT_VERIFY_URL = "https://sandbox.itunes.apple.com/verifyReceipt" #test
# APPLE_RECEIPT_VERIFY_URL = "https://buy.itunes.apple.com/verifyReceipt"     #real
def self.verify_receipt(b64_receipt)
  json_resp = nil
  url = URI.parse(APPLE_RECEIPT_VERIFY_URL)
  http = Net::HTTP.new(url.host, url.port)
  http.use_ssl = true
  http.verify_mode = OpenSSL::SSL::VERIFY_NONE
  json_request = {'receipt-data' => b64_receipt, 'password' => APPLE_SHARED_PASS}.to_json
  resp, resp_body = http.post(url.path, json_request.to_s, {'Content-Type' => 'application/x-www-form-urlencoded'})
  if resp.code == '200'
    json_resp = JSON.parse(resp_body)
    logger.info "verify_receipt response: #{json_resp}"
  end
  json_resp
end
#App Store error responses
#21000 The App Store could not read the JSON object you provided.
#21002 The data in the receipt-data property was malformed.
#21003 The receipt could not be authenticated.
#21004 The shared secret you provided does not match the shared secret on file for your account.
#21005 The receipt server is not currently available.
#21006 This receipt is valid but the subscription has expired.

UPDATE

My app got rejected, because the meta data was not clearly stating some info about the auto-renewable subscriptions.

In your meta data at iTunes Connect (in your app description): You need to clearly and conspicuously disclose to users the following information regarding Your auto-renewing subscription:  

  • Title of publication or service
  • Length of subscription (time period and/or number of deliveries during each subscription period)
  • Price of subscription, and price per issue if appropriate
  • Payment will be charged to iTunes Account at confirmation of purchase
  • Subscription automatically renews unless auto-renew is turned off at least 24-hours before the end of the current period
  • Account will be charged for renewal within 24-hours prior to the end of the current period, and identify the cost of the renewal
  • Subscriptions may be managed by the user and auto-renewal may be turned off by going to the user’s Account Settings after purchase
  • No cancellation of the current subscription is allowed during active subscription period
  • Links to Your Privacy Policy and Terms of Use
  • Any unused portion of a free trial period, if offered, will be forfeited when the user purchases a subscription to that publication."

UPDATE II

App got rejected again. The subscription receipt is not verified by the production AppStore verify url. I can not reproduce this problem in the sandbox, my app works flawless. The only way to debug this, is to submit the app again for review and look at the server log.

UPDATE III

Another rejection. Meanwhile Apple documented two more statuses:

#21007 This receipt is a sandbox receipt, but it was sent to the production service for verification.
#21008 This receipt is a production receipt, but it was sent to the sandbox service for verification.

Before one submits the app for review, one should not switch the server to production receipt verify url. if one does, one gets status 21007 returned on verification.

This time the rejection reads like this:

Application initiates the In App Purchase process in a non-standard manner. We have included the following details to help explain the issue and hope you’ll consider revising and resubmitting your application.

iTunes username & password are being asked for immediately on application launch. Please refer to the attached screenshot for more information.

I have no clue why this happens. Does the password dialog pop up because a previous transaction is being restored? or does it pop up at the point of requesting products information from the app store?

UPDATE IV

I got it right after 5 rejections. My code was doing the most obvious error. One should really make sure to always finish transactions when they are delivered to the app.

If transactions aren't finished, they get delivered back to the app and things go strangely wrong.

One needs to initiate a payment first, like this:

//make the payment
SKPayment *payment = [SKPayment paymentWithProductIdentifier:productIdentifier];
[[SKPaymentQueue defaultQueue] addPayment:payment];

Then the app will shortly resign its active state and this method on the app delegate is called:

- (void)applicationWillResignActive:(UIApplication *)application

While the app is inactive, the App Store pops up its dialogs. as the app becomes active again:

- (void)applicationDidBecomeActive:(UIApplication *)application

The OS delivers the transaction through:

- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions
{

  for (SKPaymentTransaction *transaction in transactions)
  {

    switch (transaction.transactionState)
    {
        case SKPaymentTransactionStatePurchased: {
            [self completeTransaction:transaction];
            break;
        }
        case SKPaymentTransactionStateFailed: {
            [self failedTransaction:transaction];
            break;
        }
        case SKPaymentTransactionStateRestored: {
            [self restoreTransaction:transaction];
            break;
        }
        default:
            break;
      }
  }
}

And then one completes the transaction:

//a fresh purchase
- (void) completeTransaction: (SKPaymentTransaction *)transaction
{
    [self recordTransaction: transaction];
    [[SKPaymentQueue defaultQueue] finishTransaction: transaction]; 
}

See, how one calls the method finishTransaction right after passing the received transaction to recordTransaction, which then calls the apps server and does the subscription receipt verification with the App Store. Like this:

- (void)recordTransaction: (SKPaymentTransaction *)transaction 
{
    [self subscribeWithTransaction:transaction];
}


- (void)subscribeWithTransaction:(SKPaymentTransaction*)transaction {

    NSData *receiptData = [transaction transactionReceipt];
    NSString *receiptEncoded = [Kriya base64encode:(uint8_t*)receiptData.bytes length:receiptData.length];//encode to base64 before sending

    NSString *urlString = [NSString stringWithFormat:@"%@/api/%@/%@/subscribe", [Kriya server_url], APP_ID, [Kriya deviceId]];

    NSURL *url = [NSURL URLWithString:urlString];
    ASIFormDataRequest *request = [[[ASIFormDataRequest alloc] initWithURL:url] autorelease];
    [request setPostValue:[[transaction payment] productIdentifier] forKey:@"product"];
    [request setPostValue:receiptEncoded forKey:@"receipt"];
    [request setPostValue:[Kriya deviceModelString] forKey:@"model"];
    [request setPostValue:[Kriya deviceiOSString] forKey:@"ios"];
    [request setPostValue:[appDelegate version] forKey:@"v"];

    [request setDidFinishSelector:@selector(subscribeWithTransactionFinished:)];
    [request setDidFailSelector:@selector(subscribeWithTransactionFailed:)];
    [request setDelegate:self];

    [request startAsynchronous];

}

Previously my code was trying to call finishTransaction only after my server verified the receipt, but by then the transaction was somehow lost already. so just make sure to finishTransaction as soon as possible.

Also another problem one can run into is the fact, that when the app is in the sandbox it calls the sandbox App Store verification url, but when it is in review, it is somehow between worlds. So i had to change my server code like this:

APPLE_SHARED_PASS = "83f1ec5e7d864e89beef4d2402091cd0" #you can get this in iTunes Connect
APPLE_RECEIPT_VERIFY_URL_SANDBOX    = "https://sandbox.itunes.apple.com/verifyReceipt"
APPLE_RECEIPT_VERIFY_URL_PRODUCTION = "https://buy.itunes.apple.com/verifyReceipt"

  def self.verify_receipt_for(b64_receipt, receipt_verify_url)
    json_resp = nil
    url = URI.parse(receipt_verify_url)
    http = Net::HTTP.new(url.host, url.port)
    http.use_ssl = true
    http.verify_mode = OpenSSL::SSL::VERIFY_NONE
    json_request = {'receipt-data' => b64_receipt, 'password' => APPLE_SHARED_PASS}.to_json
    resp, resp_body = http.post(url.path, json_request.to_s, {'Content-Type' => 'application/x-www-form-urlencoded'})
    if resp.code == '200'
      json_resp = JSON.parse(resp_body)
    end
    json_resp
end

def self.verify_receipt(b64_receipt)
    json_resp = Subscription.verify_receipt_for(b64_receipt, APPLE_RECEIPT_VERIFY_URL_PRODUCTION)
    if json_resp!=nil
      if json_resp.kind_of? Hash
        if json_resp['status']==21007 
          #try the sandbox then
          json_resp = Subscription.verify_receipt_for(b64_receipt, APPLE_RECEIPT_VERIFY_URL_SANDBOX)
        end
      end
    end
    json_resp
end

So basically one always verifies with the production URL, but if it returns 21007 code, then it means a sandbox receipt was sent to the production URL and then one simply tries again with the sandbox URL. This way your app works the same in sandbox and production mode.

And finally Apple wanted me to add a RESTORE button next to the subscription buttons, to handle the case of multiple devices owned by one user. This button then calls [[SKPaymentQueue defaultQueue] restoreCompletedTransactions]; and the app will be deliver with restored transactions (if any).

Also, sometimes the test user accounts get contaminated somehow and things stop working and you may get a "Can not connect to iTunes store" message when subscribing. It helps to create a new test user.

Here is the rest of the relevant code:

- (void) restoreTransaction: (SKPaymentTransaction *)transaction
{
    [self recordTransaction: transaction];
    [[SKPaymentQueue defaultQueue] finishTransaction: transaction]; 
}

- (void) failedTransaction: (SKPaymentTransaction *)transaction
{
    if (transaction.error.code == SKErrorPaymentCancelled)
    {
        //present error to user here 
    }
    [[SKPaymentQueue defaultQueue] finishTransaction: transaction];    

}

I wish you a smooth InAppPurchase programming experience. :-)