Accept payments for digital goods on iOS with your own checkout page
Open your own checkout to sell in-app digital goods and subscriptions using Payment Element.
For digital products, content, and subscriptions sold in the United States or European Economic Area (EEA), your iOS app can accept Apple Pay using Elements.
If you have a limited number of products and prices, you can instead use Payment Links.
In other regions, your app can’t accept Apple Pay for digital products, content, or subscriptions.
This guide describes how to sell a subscription in your app using Elements to redirect your customers to your own checkout page.
If you already have your own checkout page that uses Elements, you can skip to the Set up universal links step.
Note
If your business is new to Stripe, processes a high volume of payments, and has advanced integration needs, contact our sales team
What you’ll build
This guide shows you how to:
- Collect payment information with your own checkout page using Elements.
- Model your subscriptions with Products, Prices, and either Customers or customer-configured Accounts.
- Use universal links to redirect directly to your app from Checkout.
- Monitor webhooks to update your customers’ in-app subscriptions.
This guide only describes the process for selling in-app digital goods. If you sell any of the following, use the native iOS payment guide instead:
- Physical items
- Goods and services intended for consumption outside your app
- Real-time person-to-person services
Use the Accounts v2 API to represent customers
The Accounts v2 API is generally available for Connect users, and in public preview for other Stripe users. If you’re part of the Accounts v2 preview, you need to specify a preview version in your code.
To join the Accounts v2 preview, go to Account previews and features in your Dashboard and enable Reusable payment methods for Global Payouts.
For most use cases, we recommend modeling your customers as customer-configured Account objects instead of using Customer objects.
Set up Stripe Server-side
First, register for a Stripe account.
Then add the Stripe API library to your back end:
Command Line
Select a language
Ruby
Python
PHP
Java
Node.js
Go
.NET
No results
# Available as a gem
sudo gem install stripe
Gemfile
Select a language
Ruby
Python
PHP
Java
Node.js
Go
.NET
No results
# If you use bundler, you can add this line to your Gemfile
gem 'stripe'
Next, install the Stripe CLI. The CLI provides the required webhook testing, and you can run it to create your products and prices.
Install the Stripe CLI with npm:
Command Line
npm install -g @stripe/cli@latest
After installation, log in to your Stripe account:
Command Line
stripe login
After you install the CLI, you can also install agent tooling, or set up autocompletion.
Note
For more installation options for Windows, macOS, Linux, and Docker, see the Stripe CLI readme on GitHub.
Create products and prices
Create your products and their prices in the Dashboard or with the Stripe CLI. This example uses a product and price to represent a subscription product with a 9.99 USD monthly price.
- Navigate to the Add a product page and create a subscription product with a 9.99 USD monthly price.
- After you create the price, record the price ID so you can use it in subsequent steps. Price IDs look like this:
price_G0FvDp6vZvdwRZ. - Next, click Copy to live mode to clone your product from a testing environment to live mode.
Create customers Server-side
Each time your customer goes to your checkout page, create a customer-configured Account object representing your customer, if one doesn’t already exist.
Your server needs to handle:
- Customer creation (if a matching Stripe Account doesn’t exist yet).
- Subscription creation in an incomplete state.
- Returning the PaymentIntent client secret to the front end.
- Webhook handling so you can update your customer’s subscription status in your own database.
Node.js
// Don't put any keys in code. See https://docs.stripe.com/keys-best-practices.
// Find your keys at https://dashboard.stripe.com/apikeys.
const stripe = require('stripe')('sk_test_BQokikJOvBiI2HlWgH4olfQ2');
// This assumes your app has an existing customer database, which we'll call `myUserDB`.
const user = myUserDB.getUser("jennyrosen");
if (!user.stripeCustomerAccountID) {
const customer_account = await stripe.v2.core.accounts.create({
display_name: user.name,
contact_email: user.email,
});
// Set the user's Stripe customer Account ID for later retrieval. Associating the user with the Stripe object ID lets your customers recover their purchases.
user.stripeCustomerAccountID = customer_account.id;
}
If the customer changes their email on the checkout page, the Account object updates with the new email.
Create a Subscription Server-side
When creating a subscription to use the Payment Element, you typically pass payment_behavior: 'default_incomplete'. This tells Stripe to create a Subscription in incomplete status and generate a PaymentIntent for the initial payment.
Note
Store the subscription.id in your database to manage future subscription events such as cancellations, upgrades, and downgrades.
Node.js
// This example sets up an endpoint using the Express framework.
const express = require('express');
const app = express();
const stripe = require('stripe')('sk_test_BQokikJOvBiI2HlWgH4olfQ2');
app.post('/create-subscription', async (req, res) => {
const { priceId, customerAccountId } = req.body;
// Create the subscription
// setting payment_behavior to "default_incomplete" ensures we get a PaymentIntent
// that we can confirm on the client using the Payment Element
const subscription = await stripe.subscriptions.create({
customer_account: customerAccountId,
items: [{ price: priceId }],
payment_behavior: 'default_incomplete',
expand: ['latest_invoice.payment_intent'],
});
// Associate the subscription ID with the user in your database
myUserDB.addUserSubscription("jennyrosen", subscription.id);
// Get the PaymentIntent client secret
const paymentIntent = subscription.latest_invoice.payment_intent;
const clientSecret = paymentIntent.client_secret;
return res.json({
subscriptionId: subscription.id,
clientSecret: clientSecret,
});
});
app.post('/login', async (req, res) => {
const token = myUserDB.login(req.body.login_details)
res.json({token: token})
});
app.listen(4242, () => console.log(`Listening on port ${4242}!`));
Note
Apple Pay is enabled by default and automatically appears in the Payment Element when a customer uses a supported device and has saved at least one card in the Wallet app. To accept additional payment methods, enable them in your Dashboard. See payment methods overview for more details.
Set up universal links
Universal links allow your checkout page to deeply link into your app. To configure a universal link:
- Add an apple-app-site-association file to your domain.
- Add an Associated Domains entitlement to your app.
- Add a fallback page for your checkout redirect URLs.
Define the associated domains
Add a file to your domain at .well-known/apple-app-site-association to define the URLs that your app handles. Prepend your App ID with your Team ID, which you can find on the Membership page of the Apple Developer Portal.
.well-known/apple-app-site-association
{
"applinks": {
"apps": [],
"details": [
{
"appIDs": [ "A28BC3DEF9.com.example.MyApp1",
"A28BC3DEF9.com.example.MyApp1-Debug" ],
"components": [
{
"/": "/checkout_redirect*",
"comment": "Matches any URL whose path starts with /checkout_redirect"
}
]
}
]
}
}
You must serve the file with the related setting type application/json. Use curl -I to confirm the content type:
Command Line
See Apple’s page on supporting associated domains for more details.
Add an Associated Domains entitlement to your app
- Open the Signing & Capabilities pane of your app’s target.
- Click + Capability , then select Associated Domains .
- Add an entry for applinks:example. com to the Associated Domains list.
For more information on universal links, see Apple’s universal links documentation.
Although iOS intercepts links to the URLs defined in your apple-app-site-association file, you might encounter situations where the redirect fails to open your app.
Create a fallback page at your success and cancel URLs. For example, you can have a /checkout_redirect/success page and a /checkout_redirect/cancel page.
Open Checkout in Safari Client-side
Add a checkout button to your app. This button opens your custom checkout page in Safari.
CheckoutView.swift
import Foundation
import SwiftUI
import StoreKit
struct BuySubscriptionsView: View {
@EnvironmentObject var myBackend: MyBackend
@State var paymentComplete = false
var body: some View {
// Check if payments are blocked by Parental Controls on this device.
if !SKPaymentQueue.canMakePayments() {
Text("Payments are disabled on this device.")
} else {
if paymentComplete {
Text("Payment complete!")
} else {
Button {
UIApplication.shared.open("https://example.com/checkout", options: [:], completionHandler: nil)
} label: {
Text("Subscribe")
}.onOpenURL { url in
// Handle the universal link from Checkout.
if url.absoluteString.contains("success") {
// The payment was completed. Show a success
// page and fetch the latest customer entitlements
// from your server.
paymentComplete = true
}
}
}
}
}
}
Redirect back to your app Server-side
With Elements, make sure you redirect customers back to your app (using the registered universal link) on a successful payment confirmation.
Item 1
Handle order fulfillment Server-side
When the customer completes the initial payment or when subsequent recurring payments occur, Stripe sends events such as:
- invoice. payment _ succeeded
- customer. subscription. updated (even if you use customer-configured Accounts )
- invoice. payment _ failed
Listen for these events in your webhook endpoint. For example:
Node.js
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
} catch (err) {
console.error('Webhook signature verification failed.', err.message);
return res.sendStatus(400);
}
switch (event.type) {
case 'invoice.payment_succeeded': {
const invoice = event.data.object;
// Mark subscription as active in your database
// For example, invoice.subscription -> "sub_abc123"
console.log('Payment succeeded');
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object;
console.log('Payment failed - notify the customer to update their payment methods');
break;
}
case 'customer.subscription.updated': {
const subscription = event.data.object;
// For example, handle pause, cancellation, or other changes
console.log(`Subscription updated: ${subscription.id}`);
break;
}
default:
console.log(`Unhandled event type ${event.type}`);
}
res.json({ received: true });
});
To test your integration, you can monitor events in the Dashboard or using the Stripe CLI. When developing in production, set up a webhook endpoint and subscribe to appropriate event types. If you don’t know your the related setting key, click the webhook in the Dashboard to view it.
Testing
To test your that your checkout button works, do the following:
- Click the checkout button, which redirects you to your checkout with Stripe’s Payment Element.
- Enter the test number , a three-digit CVC, a future expiration date, and any valid postal code.
- Tap Pay .
- The invoice. payment _ succeeded webhook fires, and Stripe notifies your server about the transaction.
- You’re redirected back to your app.
If your integration isn’t working, see additional testing resources.
