Building a React Native POS App with Stripe Terminal and Tap to Pay
We recently shipped a mobile POS app for a client whose staff take payments in the field. It had to accept contactless payments on a phone, present one experience over two existing platforms, and recover safely when a charge succeeded but the network response failed. Those constraints drove most of the architecture.
Tap to Pay means the phone is the reader
Dedicated readers add friction to a field rollout. Staff have to keep them paired, charged, and nearby. With Stripe Terminal's React Native SDK, a supported iPhone becomes the contactless reader. Staff can accept cards and phone wallets without carrying another device.
We also support the BBPOS WisePOS E and the pocket-sized M2 for teams that need chip and swipe. Both options use the same Stripe Terminal React Native SDK integration. The app asks Terminal to collect a payment, while the connection layer handles the reader choice.
Two things worth knowing going in:
- Tap to Pay and Bluetooth readers only work on physical devices. Plan for on-device testing early, because you can't validate the real payment path in a simulator.
- You will manage runtime permissions. Terminal needs Bluetooth and Location. Handle the case where a user revokes them mid-session, not just the first-launch prompt.
One app, two backends: reach for a BFF
Our client runs two platforms with different APIs and data models. The app sends every request through a small Node and Express backend-for-frontend. The BFF uses session context to route the request and normalizes the response.
The React Native app receives one contract for records, catalogs, and payments. Platform-specific field names and missing endpoints stay in the BFF, where we can test them without spreading conditional logic through the UI.
The payment flow that can't double-charge
The riskiest failure happens after Stripe accepts a payment but before the app receives confirmation. If the staff member retries, the customer could be charged twice.
We use the PaymentIntent ID as the transaction's identity from creation through sale recording. The backend records at most one sale for that ID:
POST /payments/record { "paymentIntentId": "pi_123", ... } // Server: has pi_123 already been recorded? // yes -> return the existing record, do nothing else // no -> record it, then return
A network retry now returns the existing sale instead of creating another one. The device also stores the last successful PaymentIntent ID, so the app can recover after a crash without starting a new charge.
Automated tests cover this exact failure path. If a future change allows the same PaymentIntent to create two sale records, CI catches it before release.
OAuth with PKCE and native secure storage
Staff sign in with OAuth 2.0 and PKCE, which avoids storing a client secret in the mobile app. The app stores session tokens in native Keychain or Keystore storage through Expo. The backend receives the authenticated session context it needs to route each user to the correct platform.
What we would repeat
- Start testing Stripe Terminal on physical devices early. A simulator can cover the interface, but it cannot validate the payment hardware path.
- Use the PaymentIntent ID as a durable transaction key across the device and backend.
- Keep multiple upstream platforms behind one BFF contract.
- Lead with Tap to Pay, then support dedicated readers for teams that need chip or swipe.
Building something similar, whether that is in-person payments, a POS on mobile, or a unified app over multiple systems? We'd love to help.
