It’s no longer sufficient to run an exclusively web-based application. You need a mobile strategy if you’re to stay ahead of the competition. However managing payments on mobile is a developer’s worst nightmare. Stripe has solved this very well for web-based applications, but on mobile, it’s a different story.
How Payments Work on iOS
Purchases in iOS are available either in-app or upfront. Payments are taken care of by Apple and they take a hefty 30% commission of the sale. For their part, they cover security of the user data and ensure that the purchases are available in all of the customer’s devices.
While this sounds great on the surface, it is actually a pain to manage in real life. You’ll have to ensure that the customers have access to this content at all times. If you’re running a subscription which works across all devices, things get a little tricky. Apple does not send you any information on who subscribed. Apple protects the user’s identity very well by making the developer’s life harder.
In this article, I’ll help you learn how to accept iOS payments and subscriptions from your Rails app, including the common gotchas, and how to stay on Apple’s good side.
Setup
Before we begin, you’ll have to add the catalog of all that you’re selling inside the “In-App Purchases” page on your Apple dashboard. It is advisable to use the same ID as in your Rails app, so it’s easy to associate the purchases.
Note: This article assumes that you have an iOS app and sufficient knowledge on how to integrate with in-app purchases. I’ll be focusing only on how to manage it from your Rails app and won’t show any ObjectiveC code examples.
We will be using the app that we built in my previous article on Stripe Subscriptions as a base. It uses MongoDB for the database, but the examples here should work with ActiveRecord with just some minor changes. With that out of the way, let us begin.
InApp Purchases
When a user purchases an item from your app, the Rails app will send it to Apple’s servers for processing. Once it’s done, Apple will send you a receipt for validation. You need to validate this receipt before granting access to this user. While you can directly call Apple’s servers from your app, it is not recommended. The ideal way is to handle this through your backend.
Let’s build an endpoint to receive these requests:
# routes.rb
# IOS subscription
post '/receipt_validate/' => 'checkouts#handle_ios_transaction'
In app/controllers/checkouts_controller:
# checkouts_controller.rb
def handle_ios_transaction
item_id = params[:item_id]
item = Items.find(item_id.to_s)
if Rails.env == 'development' or Rails.env == 'test' or Rails.env == 'staging'
apple_receipt_verify_url = "https://sandbox.itunes.apple.com/verifyReceipt"
else
#apple_receipt_verify_url = "https://sandbox.itunes.apple.com/verifyReceipt"
apple_receipt_verify_url = "https://buy.itunes.apple.com/verifyReceipt"
end
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
valid = false
json_request = {'receipt-data' => params[:receipt_data] }.to_json
resp = http.post(url.path, json_request, {'Content-Type' => 'application/x-www-form-urlencoded'})
resp_body = resp
json_resp = JSON.parse(resp_body.body)
if resp.code == '200'
if json_resp['status'] == 0
valid = true
current_IAP_receipt = json_resp['receipt']['in_app'].find {|x| x['product_id'] == item_id}
respond_to do |format|
format.json { render json: {message: "purchase successful!"} and return }
end
else
Rails.logger.info("Apple_#{Rails.env} json_resp for verify_itunes #{json_resp['status']}")
respond_to do |format|
format.json { render json: {status: "invalid", errorCode: "#{json_resp['status']}"} and return }
end
end
else
format.json { render json: {status: "invalid", resp: "#{resp.code}"} and return }
end
end
In config/environments/development.rb:
ITUNES = {:receipt_url => "https://sandbox.itunes.apple.com/verifyReceipt"}
Finally, in config/environments/production.rb:
ITUNES = {:receipt_url => "https://buy.itunes.apple.com/verifyReceipt"}
Okay, that’s some verbose code. Don’t worry if you don’t get it at the first glance, I will walk you through it.
Apple has 2 environments: sandbox, and production. In the sandbox environment, your account isn’t charged. Apple will generate a test receipt instead, and will work only in sandbox environments. We need to make sure to validate the receipts with the appropriate environments, otherwise it will lead to a lot of issues.
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
valid = false
json_request = {'receipt-data' =>params[:receipt_data] }.to_json
resp = http.post(url.path, json_request, {'Content-Type' => 'application/x-www-form-urlencoded'})
resp_body = resp
json_resp = JSON.parse(resp_body.body)
Then we’re using Ruby’s HTTP module to send a POST request to Apple’s servers with the receipt data. If the payment is successful Apple will respond with a status code of 0. The detailed list of status codes is available in the developer docs.
Note: The receipt data is base64 encoded.
If the receipt is valid, then save the receipt data into our database, for future use. Add a new model for ITunes receipts:
# models/itunes_receipt.rb
class ItunesReceipt
include Mongoid::Document
include Mongoid::Timestamps
include Mongoid::Paranoia
include Mongoid::Attributes::Dynamic
field :original_purchase_date_pst, type: String
field :purchase_date_ms, type: String
field :unique_identifier, type: String
field :original_transaction_id, type: String
field :bvrs, type: String
field :transaction_id, type: String
field :quantity, type: String
field :unique_vendor_identifier, type: String
field :item_id, type: String
field :product_id, type: String
field :purchase_date, type: String
field :original_purchase_date, type: String
field :purchase_date_pst, type: String
field :bid, type: String
field :original_purchase_date_ms, type: String
field :status, type: String
end
Save the following in app/controllers/checkouts_controller.rb:
def save_receipt(receipt_data)
receipt = Itunesreceipt.new
receipt.original_purchase_date_pst = receipt_data['original_purchase_date_pst']
receipt.purchase_date_ms = receipt_data['purchase_date_ms']
receipt.original_transaction_id = receipt_data['original_transaction_id']
receipt.transaction_id = receipt_data['transaction_id']
receipt.quantity = receipt_data['quantity']
receipt.product_id = receipt_data['product_id']
receipt.purchase_date = receipt_data['purchase_date']
receipt.original_purchase_date = receipt_data['original_purchase_date']
receipt.purchase_date_pst = receipt_data['purchase_date_pst']
receipt.original_purchase_date_ms = receipt_data['original_purchase_date_ms']
receipt.save
end
Wait, how will you share this purchase with my website? Pass the user id with the validation request, if available. With that, we can associate the purchase with the user.
Edit your checkouts_controller.rb:
# checkouts_controller.rb
def handle_ios_transaction
user = User.find(params[:user_id])
#...
if json_resp['status'] == 0
valid = true
current_IAP_receipt = json_resp['receipt']['in_app'].find {|x| x['product_id'] == item_id}
if user
current_IAP_receipt.user_id = user.id
current_IAP_receipt.save
end
respond_to do |format|
format.json { render json: {message: "purchase successful!"} and return }
end
#...
end
Set the relationship in itunes_receipt.rb
:
class ItunesReceipt
#....
belongs_to :user
#....
end
Setting Up a Subscription
Subscriptions are a great way to build a recurring customer base and generate a constant flow of cash. iOS has 2 types of subscriptions: Non-renewable and Auto-renewable. Non-renewable subscriptions come with an expiry date, and in the case of renewable subscriptions, Apple renews the contract automatically on the end date.
Non-renewable Subscription
To work with Non-renewable subscriptions, in itunes_receipt.rb add:
class ItunesReceipt
#..........
field :expiry_date_ms, type: String
#..........
def is_expired?
Time.now.to_i > self.expired_date_ms.to_i
end
end
Non-renewable subscriptions work pretty similarly to In-app purchases. Just save the expiry_date
in your receipt model and validate against that every time the content is accessed.
Renewable Subscriptions
On the surface, renewable subscriptions look very similar to an In-app purchase but it is a little tricky. Once the subscription ends, Apple automatically renews it, but there is no way for you to know whether the user has cancelled it or not.
Let us start by modelling our subscription object:
class IosSubscription
include Mongoid::Document
include Mongoid::Timestamps
include Mongoid::Paranoia
include Mongoid::Attributes::Dynamic
#Fields from Apple
field :original_purchase_date, type: String
field :original_purchase_date_pst, type: String
field :original_purchase_date_ms, type: String
field :product_id, type: String
field :is_trial_period, type: Boolean
field :purchase_date, type: String
field :purchase_date_pst, type: String
field :purchase_date_ms, type: String
field :expires_date, type: String
field :expires_date_ms, type: String
field :expires_date_pst, type: String
field :original_transaction_id, type: String
field :transaction_id, type: String
field :web_order_line_item_id, type: String
field :quantity, type: Integer
field :receipt_data, type: String
#Flag for production/test receipts
field :mode, type: String
field :udid, type: String
belongs_to :user
end
and in your checkouts_controller.rb
, add:
def verify_subscription_ios
user_id = params[:user_id]
@UDID = params[:udid]
@user = User.find(user_id) unless user_id.nil?
@source = "ipad"
@shared_secret = ITUNES[:secret]
@amount = params[:amount]
@apple_receipt_verify_url = ITUNES[:receipt_url]
json_resp = validate_receipt(@apple_receipt_verify_url)
if @resp.code == '200' and json_resp['status'] == 0
save_receipt(json_resp)
else
respond_to do |format|
format.json { render json: {status: "invalid", mode: @mode, errorCode: "#{json_resp['status']}"} and return }
end
end
end
private
# Persist the receipt data in production server
def save_receipt(json_resp)
@latest_receipt = json_resp['latest_receipt_info'].last
#Generate the receipt
create_receipt
@valid = true
respond_to do |format|
format.json { render json: {status: true, message: "purchase successful!", mode: @mode, expires_at: DateTime.parse(@ios_subscription.expires_date_pst).strftime('%m/%d/%Y')} and return }
end
end
#Create receipt only if needed
def create_receipt
log "Checking subscription receipt"
# If the original transaction id is the same as the current transaction id then this is a new record
@ios_subscription = IosSubscription.find_by(:original_transaction_id => @latest_receipt['original_transaction_id'])
if @ios_subscription.nil? and @latest_receipt['original_transaction_id'] == @latest_receipt['transaction_id']
new_receipt()
else
unless @ios_subscription.nil?
#Update Existing receipt
unless @latest_receipt['original_transaction_id'] == @latest_receipt['transaction_id']
log "Updating receipt #{@ios_subscription.id}"
@ios_subscription.expires_date = @latest_receipt['expires_date']
@ios_subscription.expires_date_ms = @latest_receipt['expires_date_ms']
@ios_subscription.expires_date_pst = @latest_receipt['expires_date_pst']
@ios_subscription.web_order_line_item_id = @latest_receipt['web_order_line_item_id']
@ios_subscription.transaction_id = @latest_receipt['transaction_id']
@ios_subscription.save
end
end
end
end
def new_receipt
@ios_subscription = IosSubscription.new
@ios_subscription.original_purchase_date = @latest_receipt['original_purchase_date']
@ios_subscription.original_purchase_date_pst = @latest_receipt['original_purchase_date_pst']
@ios_subscription.original_purchase_date_ms = @latest_receipt['original_purchase_date_ms']
@ios_subscription.purchase_date = @latest_receipt['purchase_date']
@ios_subscription.purchase_date_pst = @latest_receipt['purchase_date_pst']
@ios_subscription.purchase_date_ms = @latest_receipt['purchase_date_ms']
@ios_subscription.expires_date = @latest_receipt['expires_date']
@ios_subscription.expires_date_ms = @latest_receipt['expires_date_ms']
@ios_subscription.expires_date_pst = @latest_receipt['expires_date_pst']
@ios_subscription.original_transaction_id = @latest_receipt['original_transaction_id']
@ios_subscription.transaction_id = @latest_receipt['transaction_id']
product_id = @latest_receipt['product_id']
@ios_subscription.product_id = product_id.split("_").first
@ios_subscription.quantity = @latest_receipt['quantity']
@ios_subscription.web_order_line_item_id = @latest_receipt['web_order_line_item_id']
@ios_subscription.udid = @UDID
@ios_subscription.receipt_data = @receipt_data
@ios_subscription.mode = @mode
# Associate ios subscription to the current user
unless @user.nil?
@user.ios_subscription = @ios_subscription
@user.save
end
@ios_subscription.save
end
Okay, this is very similar to the in-app purchase. We’re validating the receipt with Apple, and if it’s valid save the receipt. If a subscription already exists, we then need to update the expiry date accordingly.
However, this leads to a lot of unnecessary network calls and will hog your backend if you’ve a large customer base. To offset this, it is a common practice to store the expiry date in your ios database and only when the expiry date is near, send a request to your Rails app. This will improve the responsiveness of your app.
Handle Sandbox Receipts in Production
When you submit your app to the app store, you’ll point the app to production mode. Unfortunately, Apple verifies the app in Sandbox mode and will use a sandbox receipt to test your purchases. The receipt will fail, and the app might get rejected. Your API should be able to handle this.
In your checkouts_controller
:
def verify_subscription_ios
#...
if @resp.code == '200'
if json_resp['status'] == 0
save_receipt(json_resp)
elsif json_resp['status'] == 21007
@mode = "sandbox"
json_resp = validate_receipt("https://sandbox.itunes.apple.com/verifyReceipt")
save_receipt(json_resp)
else
respond_to do |format|
format.json { render json: {status: "invalid", mode: @mode, errorCode: "#{json_resp['status']}"} and return }
end
end
#...
end
If the error code is 21007
, set the mode to ‘sandbox’ and validate it against the sandbox environment.
Other Thoughts
Apple doesn’t have an API to cancel the subscription. Users will have to manually cancel it from the Settings for the application. Also, when a user cancels there is no way for you to know who cancelled. To make matters worse, most users don’t know how to cancel from an iOS device. In one of my previous projects, we’ve received an array of negative reviews and tons of angry emails from customers who couldn’t figure it out. Make sure you leave your support email and detailed steps on how to cancel.
Also, it is very difficult for you offer discounts to an existing customer or a reviewer. If you’re running a special promotional campaign on your website, it’s impossible to bring it down to your iOS app.
You need to be mindful of this and have a way for your users to interact with your support team to reduce friction with your customers.
Wrapping Up
Auto-renewal subscriptions are a great way to generate recurring revenue, but if not managed properly they can make your life miserable. I hope you enjoyed this article. Feel free to join us in a discussion below.
Frequently Asked Questions (FAQs) about Handling iOS Payments in Rails
How can I integrate iOS payments into my Rails application?
Integrating iOS payments into your Rails application involves several steps. First, you need to set up your iOS application for in-app purchases through the Apple Developer portal. This involves creating a new in-app purchase product and associating it with your app. Next, you need to implement the in-app purchase functionality in your iOS app using the StoreKit framework. Finally, you need to set up your Rails backend to validate receipts and manage subscriptions. This involves setting up a route to handle receipt validation requests, implementing the receipt validation logic, and managing subscription states.
What is the role of the Rails backend in handling iOS payments?
The Rails backend plays a crucial role in handling iOS payments. It is responsible for validating receipts and managing subscriptions. When a user makes a purchase in the iOS app, the app sends a receipt to the Rails backend. The backend then sends this receipt to Apple’s servers for validation. If the receipt is valid, the backend updates the user’s subscription status accordingly. The backend also needs to handle renewals and cancellations of subscriptions.
How can I validate receipts in Rails?
Receipt validation in Rails involves sending the receipt data to Apple’s servers and checking the response. You can use the net/http
library in Ruby to send a POST request to Apple’s servers with the receipt data. The response from Apple’s servers will contain the status of the receipt and the details of the purchase. You need to check the status to ensure that the receipt is valid and then update your database with the purchase details.
How can I manage subscriptions in Rails?
Managing subscriptions in Rails involves keeping track of the subscription status for each user. When a user makes a purchase, you need to update their subscription status in your database. You also need to handle renewals and cancellations. This can be done by periodically checking the receipt data for each user with Apple’s servers and updating the subscription status accordingly.
What are some common issues when integrating iOS payments with Rails?
Some common issues when integrating iOS payments with Rails include handling receipt validation errors, managing subscription renewals and cancellations, and dealing with fraudulent purchases. Receipt validation errors can occur if the receipt data is not sent correctly to Apple’s servers or if there is an issue with Apple’s servers. Subscription renewals and cancellations can be tricky to handle because you need to periodically check the receipt data for each user. Fraudulent purchases can be a problem because they can lead to revenue loss.
How can I handle receipt validation errors?
Receipt validation errors can be handled by checking the status code returned by Apple’s servers. If the status code indicates an error, you can retry the validation request or show an error message to the user. It’s also important to log these errors for debugging purposes.
How can I handle subscription renewals and cancellations?
Subscription renewals and cancellations can be handled by periodically checking the receipt data for each user with Apple’s servers. If the receipt data indicates that a subscription has been renewed or cancelled, you can update the subscription status in your database accordingly.
How can I prevent fraudulent purchases?
Preventing fraudulent purchases involves validating receipts and checking the purchase details. If the receipt is valid and the purchase details match the expected values, you can consider the purchase to be legitimate. Otherwise, you should not update the user’s subscription status and should investigate the issue further.
Can I test iOS payments in Rails without making actual purchases?
Yes, you can test iOS payments in Rails without making actual purchases by using Apple’s sandbox environment. This allows you to simulate purchases and receipt validation without any actual money being involved.
What are some best practices for handling iOS payments in Rails?
Some best practices for handling iOS payments in Rails include validating receipts, managing subscriptions, handling errors, preventing fraudulent purchases, and testing your implementation. It’s also important to keep your code clean and organized, and to use version control to keep track of changes.
Hola! I'm a Fullstack developer and a strong advocate of Mobile first design. I'm running a digital children's startup for kids and I lead the engineering efforts there. In my free time I ramble about technology, and consult startups.