This guide walks through a Stripe Terminal iOS integration in Swift from the first backend route to a captured payment. It follows the current Stripe docs for iOS SDK 6.x, covers the parts that trip people up (permissions, reader updates, retries, double charges), and shows how to choose between a card reader and Tap to Pay on iPhone.
Quick answer: A Stripe Terminal iOS integration needs four things: a backend route that creates ConnectionTokens, the Terminal iOS SDK (iOS 16 or later) initialized once with a token provider, a connected reader (or the simulated reader while you build), and a payment flow that creates a PaymentIntent and processes it. Never put your Stripe secret key in the app.
Table of Contents
How a Stripe Terminal iOS Integration Works
Three parts talk to each other in every transaction: your iOS app, your backend and Stripe. The app runs the Terminal SDK and talks to the reader. Your backend holds the secret key and creates ConnectionTokens. Stripe authorizes the payment and tracks it as a PaymentIntent.
The SDK asks your backend for a ConnectionToken whenever it needs to authenticate with Stripe or a reader. That token lets a device connect to any reader on your account, so the endpoint must be authenticated and protected from cross-site request forgery. Stripe’s own docs say not to cache or hardcode the token, because the SDK manages its lifecycle.
PaymentIntents can be created on the client or on your server. Creating them on the server makes sense when the order details live there. In both cases the card_present payment method type is what marks a payment as in person.
This setup means a working server endpoint must exist before your app can do anything useful. It also means your phone must reach that server during testing, so localhost won’t work unless you use a tunnel such as ngrok.
What You Need Before You Start
You need an iOS 16+ app target, a backend that can call the Stripe API, a Stripe account and a Terminal Location. A physical reader isn’t required, because the SDK includes a simulated reader. Tap to Pay on iPhone also needs an Apple entitlement.
| Requirement | Detail |
|---|---|
| iOS version | The SDK supports apps that target iOS 16 and later |
| Backend | A route that creates ConnectionTokens with your secret key |
| Location | A Terminal Location ID, used when connecting mobile readers |
| Reader | A physical reader, or the simulated reader for development |
| Tap to Pay on iPhone | Apple entitlement com.apple.developer.proximity-reader.payment.acceptance set to true |
| Permissions | Location and Bluetooth entries in Info.plist |
Step 1: Create the ConnectionToken Endpoint
Your backend needs one route that calls the Stripe API and returns the token secret. Authenticate it, because anyone who can call it can connect to your readers and take payments on your account.
In Node.js with Express, the route looks like this:
app.post('/connection_token', async (req, res) => {
const token = await stripe.terminal.connectionTokens.create();
res.json({ secret: token.secret });
});
Put this route behind your app’s existing login check before going live. If you don’t have a backend, a serverless function on AWS Lambda, Google Cloud Functions or Vercel is the lowest-overhead option. Shipping the secret key inside the app exposes it to every user.
For more server-side Stripe patterns, see Stripe API integration with PHP, Stripe webhooks in Node.js and Stripe API key setup issues.
Step 2: Install the SDK and Configure Info.plist
Add the SDK with Swift Package Manager using Stripe’s SPM repository, or integrate the XCFramework by hand. Then add the location and Bluetooth entries to Info.plist. Missing entries cause confusing initialization problems, so add them before writing any Swift.
In Xcode, choose File > Add Package Dependencies and enter https://github.com/stripe/stripe-terminal-ios-spm. Stripe’s docs list Swift Package Manager and manual installation as the supported methods. Keep the default “Up to Next Major” rule so you receive fixes without surprise breaking changes.
| Info.plist key | Why it is needed |
|---|---|
NSLocationWhenInUseUsageDescription | Stripe must know where payments happen to reduce fraud. If the SDK can’t determine the location, payments are disabled |
UIBackgroundModes with bluetooth-central | Lets a mobile reader stay in standby while the app is in the background or the phone is locked |
NSBluetoothAlwaysUsageDescription | Shows the Bluetooth permission dialog. Without it, an app that links Core Bluetooth can crash on first launch |
NSBluetoothPeripheralUsageDescription was only needed before SDK 3.4.0. If you’re using Tap to Pay on iPhone, request the development entitlement from your Apple Developer account too.
Step 3: Initialize the SDK
Initialize Terminal once, with your token provider, before you touch Terminal.shared. Do it in application(_:didFinishLaunchingWithOptions:) or in the init() of your SwiftUI App type.
First, a class that fetches the token from your backend:
import StripeTerminal
class APIClient: ConnectionTokenProvider {
static let shared = APIClient()
func fetchConnectionToken(_ completion: @escaping ConnectionTokenCompletionBlock) {
guard let url = URL(string: "https://YOUR_BACKEND_URL/connection_token") else {
fatalError("Invalid backend URL")
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
URLSession.shared.dataTask(with: request) { data, _, error in
if let data = data,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let secret = json["secret"] as? String {
completion(secret, nil)
} else {
completion(nil, error)
}
}.resume()
}
}
Then initialize the SDK:
import UIKit
import StripeTerminal
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
Terminal.initWithTokenProvider(APIClient.shared)
return true
}
}
The older Terminal.setTokenProvider call in many tutorials is not the current setup method. Stripe’s docs use initWithTokenProvider.
Step 4: Discover and Connect a Reader
Discovery finds nearby readers, and connecting binds one of them to your app. For Bluetooth readers, you also pass a Location ID so the reader downloads the right regional configuration. Always connect with a reader object from the latest discovery results.
Start discovery with a Bluetooth scan configuration:
func discoverReadersAction() throws {
let config = try BluetoothScanDiscoveryConfigurationBuilder().build()
self.discoverCancelable = Terminal.shared.discoverReaders(config, delegate: self) { error in
if let error = error {
print("discoverReaders failed: \(error)")
} else {
print("discoverReaders succeeded")
}
}
}
// MARK: DiscoveryDelegate
func terminal(_ terminal: Terminal, didUpdateDiscoveredReaders readers: [Reader]) {
// Show the readers to the user, then call connectReader for the selected one
}
During development, use the simulated reader by turning on the simulated option in the discovery configuration. The SDK’s simulated reader has no UI, but you’ll see it working when your SDK calls succeed.
Connect to the selected reader with a Location ID:
let connectionConfig = try BluetoothConnectionConfigurationBuilder(
delegate: yourMobileReaderDelegate,
locationId: "YOUR_LOCATION_ID"
).build()
Terminal.shared.connectReader(selectedReader, connectionConfig: connectionConfig) { reader, error in
if let reader = reader {
print("Connected to reader: \(reader)")
} else if let error = error {
print("connectReader failed: \(error)")
}
}
Reader Updates Can Block the Connection
Mobile readers receive required software updates when they connect, and you can’t use the reader until the update completes. Your MobileReaderDelegate receives didStartInstallingUpdate and progress callbacks, so show a progress screen. The reader battery must be above 50% for the update to install. Blocking or ignoring these callbacks causes connection failures that look random.
Do Not Pair in iOS Settings
Stripe’s docs warn against pairing the reader through iPhone Bluetooth settings. Doing so makes the reader unavailable to your app. Always connect through the SDK.
Reconnecting
The SDK attempts reconnection by default after an unexpected disconnect. Implement the reconnect callbacks in your MobileReaderDelegate so users see a message while it happens. Stripe Terminal doesn’t reconnect when the app starts, so save the serial number after a successful connection and rediscover that reader on launch.
Step 5: Collect a Payment
The simplest flow creates a PaymentIntent, then calls processPaymentIntent, which collects the card and sends it for authorization in one call. Use the two-step collect-and-confirm flow only if you need to inspect the card before authorizing.
Create the PaymentIntent from the client:
let params = try PaymentIntentParametersBuilder(amount: 1000, currency: "usd")
.setCaptureMethod(.automaticAsync)
.build()
Terminal.shared.createPaymentIntent(params) { createResult, createError in
// continue with the PaymentIntent
}
Amounts are in the smallest currency unit, so 1000 is $10.00 in USD. The SDK’s captureMethod defaults to automatic asynchronous. If you authorize and capture separately, for example with on-receipt tipping, set .manual.
If you create the PaymentIntent on your server, the payment method types must include card_present:
curl https://api.stripe.com/v1/payment_intents \
-u YOUR_SECRET_KEY: \
-d amount=1000 \
-d currency=usd \
-d "payment_method_types[]"=card_present \
-d capture_method=manual
Your app then fetches the client secret from your backend and calls retrievePaymentIntent.
Process the payment with the PaymentIntent in hand:
self.processCancelable = Terminal.shared.processPaymentIntent(
paymentIntent, collectConfig: nil, confirmConfig: nil
) { processResult, processError in
if let error = processError {
print("processPaymentIntent failed: \(error)")
} else if let processed = processResult {
// Tell your backend to capture if you used manual capture
if let stripeId = processed.stripeId {
APIClient.shared.capturePaymentIntent(stripeId) { _ in }
}
}
}
capturePaymentIntent here stands for your own backend call. With manual capture, a successful process call leaves the PaymentIntent in requires_capture, and you must capture it within two days or the authorization expires and the funds are released.
Show the Reader’s Prompts
Readers without a screen, such as the Stripe Reader M2, rely on your app to show prompts like “insert, tap or swipe” and “retry card.” Implement the reader display callbacks and put the text in your checkout UI:
func reader(_ reader: Reader, didRequestReaderInput inputOptions: ReaderInputOptions) {
readerMessageLabel.text = Terminal.stringFromReaderInputOptions(inputOptions)
}
func reader(_ reader: Reader, didRequestReaderDisplayMessage displayMessage: ReaderDisplayMessage) {
readerMessageLabel.text = Terminal.stringFromReaderDisplayMessage(displayMessage)
}
Always Offer a Cancel Button
The process and collect calls return a Cancelable. Keep it and wire it to a cancel button. A customer who changes their mind, or a reader that loses connection mid-payment, needs a clean exit, and an uncanceled collection can block the reader. As of SDK 6.0.0, a cancellation that happens on the reader fails the operation with SCPErrorCanceledByReader, while cancellation you request returns SCPErrorCanceled, so you can tell them apart.
The Two-Step Option
If you need your own logic before authorizing, call collectPaymentMethod, inspect the card details, then call confirmPaymentIntent. After collecting, you must authorize or cancel within 30 seconds. Always confirm with the Terminal SDK on the client, because server-side confirmation skips interactions such as PIN prompts and can cause failures.
Choosing a Reader for Your iOS App
For iOS, the choice is between a Bluetooth mobile reader, an internet-connected smart reader, and Tap to Pay on iPhone. The right one depends on where you take payments. Reader availability varies by country, so check Stripe’s list before ordering.
| Option | Connection | Best for | Notes |
|---|---|---|---|
| Stripe Reader M2 | Bluetooth, USB | Mobile and field payments | US only, no screen |
| BBPOS WisePad 3 | Bluetooth, USB | Mobile payments outside the US | Has a display |
| BBPOS WisePOS E | Internet | Countertop checkout | Smart reader with touchscreen |
| Stripe Reader S700 and S710 | Internet | Countertop and busy retail | S710 adds cellular |
| Stripe Reader T600 | Internet | Countertop and pole mount | 8-inch customer-facing display |
| Tap to Pay on iPhone | None | No extra hardware | Needs a compatible iPhone and Apple entitlement |
Smart readers update automatically when powered on, charged and not in use. Mobile readers update through your app when connected. Stripe’s docs list Verifone readers as a public preview. Order hardware from the Terminal shop in the Stripe Dashboard.
For a full cost picture, read Stripe Terminal cost analysis, and for POS context see digital POS system features.
Tap to Pay on iPhone
Tap to Pay on iPhone lets customers pay by tapping a card or phone against your iPhone, with no reader. It is built into the Terminal iOS SDK. It requires an iPhone XS or later running a supported iOS version, an Apple entitlement and a “How to Tap” educational overlay.
The four steps are: request the entitlement, set up the iOS SDK, connect to the Tap to Pay reader, then collect the payment. Add the entitlement key com.apple.developer.proximity-reader.payment.acceptance with a value of true, request a distribution entitlement after internal testing, and submit your app to Apple for approval.
Apple requires a “How to Tap” instructional overlay before you submit your app for review. Stripe’s docs show how to present it with Apple’s ProximityReaderDiscovery API on iOS 18 and later, and you provide a fallback for earlier versions. Tap to Pay doesn’t work on beta releases of iOS.
Some limits matter in practice. Tap to Pay on iPhone doesn’t accept chip insertion or magstripe cards, supports PIN entry on iOS 16.4 and later, and some cards in the UK, Canada and Finland need a physical insert for offline PIN, which this method can’t do. In those cases, ask for another card or use a reader.
It is available in many countries including the US, UK, Canada, Australia, Germany, France and Singapore, and in public preview in others. Check Stripe’s current list before you build.
Error Handling That Covers Real Failures
Most payment bugs come from retries. Reuse the same PaymentIntent after a decline, and never create a new one for a retry. Stripe’s docs give a simple rule per PaymentIntent status, and following it prevents double charges.
| PaymentIntent status | Meaning | What to do |
|---|---|---|
requires_payment_method | The card was declined | Process again with the same PaymentIntent and ask for a different card |
requires_confirmation | Temporary connectivity problem | Retry the same step with the same PaymentIntent |
Returned PaymentIntent is nil | The request timed out and the status is unknown | Retry the original PaymentIntent, never create a new one |
Beyond those, plan for four situations:
- Reader disconnects mid-payment. Cancel the current operation, let the SDK try to reconnect, then retry with the same PaymentIntent.
- A required reader update. Show the progress screen. The reader can’t take payments until it finishes.
- A capture request fails. If you use manual capture, a failed capture after a good authorization leaves funds uncaptured. Retry the capture and reconcile at the end of each day.
- An abandoned checkout. An uncaptured PaymentIntent can look like an unintended authorization on the customer’s statement. Cancel it if no order exists.
If a network drop happens mid-payment, retrieve the PaymentIntent by ID to see its status before deciding what to do. For server-side error patterns, see Stripe API exception handling and Stripe fraud detection rules.
Offline Mode: Taking Payments Without Connectivity
Offline mode lets some readers collect card payments when the internet drops and forward them to Stripe later. It needs the Terminal mobile SDK. Stripe lists it as supported on smart readers and mobile readers, and as a private preview on iPhone for Tap to Pay.
Two things change in code when you operate offline. A processed PaymentIntent has no stripeId, which is why Stripe’s samples branch on it. And the paymentMethod field is missing from the collected PaymentIntent, so you can’t inspect card details. Your order system must also handle a payment that is pending until it is forwarded.
Offline payments carry risk that online payments don’t, because a stored payment can be declined after it is forwarded to Stripe. Read Stripe’s offline mode pages for the current limits and liability before you turn it on, and avoid it for high-value sales.
Test Without Hardware, Then With a Real Reader
Build against the simulated reader first, then test with a physical test card and a real reader. Stripe Terminal can’t use mobile wallets such as Apple Pay in test mode, so test wallets in live conditions with small amounts.
The simulated reader accepts test cards that produce specific results:
| Test card number | Result |
|---|---|
| 4242424242424242 | Visa, approved |
| 4000000000000002 | Declined with card_declined |
| 4000000000009995 | Declined for insufficient funds |
| 4000000000000119 | Declined with a processing error |
| 4001007020000002 | Simulates an offline PIN prompt |
With a physical test card and a real reader, the decimal part of the amount sets the result:
| Amount ends in | Result |
|---|---|
| .00 | Approved |
| .01 | Declined with call_issuer |
| .05 | Declined with generic_decline |
| .55 | Declined with incorrect_pin |
Use PIN 1234 when prompted. Test reader updates too, by setting a test reader update on the connection configuration in test mode. If you hit login or mode problems while testing, see Stripe test mode not saving.
Go-Live Checklist
Before launch, confirm each of these:
- The ConnectionToken endpoint is authenticated, and the secret key isn’t in the app.
- You test successful payments, declines, interrupted connections and reader updates.
- Manual-capture PaymentIntents are captured by your backend.
- You give customers a paper or email receipt, and you tested it in live mode.
- You reconcile PaymentIntents with your order system at the end of each day.
- Webhooks are set up for
payment_intent.succeededandpayment_intent.payment_failed. - Your app is on the latest SDK release, and you have a process to stay current.
- Each reader is assigned to a Location.
Stripe’s deployment checklist covers more, including network requirements for smart readers. If you’re still setting up the account itself, start with the Stripe account setup guide.
FAQ
Can I use Stripe Terminal without a backend server?
No. Creating ConnectionTokens needs your secret key, and Stripe’s checklist says never to provision tokens from the client or embed the key in the app. Serverless functions are the lowest-overhead way to host the required endpoint.
What iOS version does the Stripe Terminal SDK need?
The SDK supports apps that target iOS 16 and later. Tap to Pay on iPhone needs an iPhone XS or later running a supported iOS version, and it doesn’t work on iOS beta releases. Check Stripe’s page for the current requirements.
How do I test Stripe Terminal without a reader?
Use the simulated reader, which is built into the SDK, and configure test cards to produce approvals or declines. Use a physical test card with a real reader when you need to test hardware, PIN entry and receipts.
Should I use processPaymentIntent or collectPaymentMethod and confirmPaymentIntent?
Use processPaymentIntent for most cases, because it needs fewer API calls. Use collect and confirm only when you need to inspect the card before authorizing, and remember you must confirm or cancel within 30 seconds after collecting.
What is card_present?
card_present is the payment method type for payments made with a physical card or wallet on a Terminal reader. Your PaymentIntent must include it when you create the intent on the server. In Canada, add interac_present to accept Interac.
Do I need an Apple entitlement for Tap to Pay on iPhone?
Yes. You request a development entitlement, add the com.apple.developer.proximity-reader.payment.acceptance key, then request a distribution entitlement after internal testing. Apple also requires the “How to Tap” overlay before app review.
How do tips work with Stripe Terminal?
Smart readers support on-reader tipping, the M2 supports receipt tipping only, and Tap to Pay uses in-app tipping. In the US, eligible users can collect a tip on the receipt when capturing payments. Set capture to manual for on-receipt tipping.
How do I avoid double charges?
Reuse the same PaymentIntent after a decline or timeout, and never create a new one for a retry. If the app loses the response, retrieve the PaymentIntent by ID and check its status before retrying.
How much does Stripe Terminal cost?
Stripe prices in-person processing and readers separately from online payments, and the rates vary by country. Check Stripe’s Terminal pricing page for your region. Our Stripe Terminal cost analysis breaks down the costs to plan for.
Build the Failure Paths First
The Terminal SDK is well built, and the simulated reader makes local development fast. In-person payments fail in ways online payments don’t: a reader loses Bluetooth, a customer pulls a card at the wrong moment, an update blocks a connection. Handle those cases before you demo to a client.
Test with a real reader in a real location, not on a desk with full Wi-Fi. Reconcile every transaction on your server instead of trusting SDK callbacks alone. Which reader will your app use first, a Bluetooth mobile reader, a smart reader or Tap to Pay?
If you need a team to build or audit an in-person payments app, see how outsourcing iOS app development can work for agencies.











