Building an In-Person Payments App with Stripe Terminal and Tap to Pay in React Native
We recently shipped a mobile point-of-sale app for a client whose staff take payments in the field throughout the day. The brief sounded simple: let anyone take a card payment on their phone. Under the hood, that simple sentence hid three of the more interesting problems in mobile payments. Here is what we learned building it.
Tap to Pay means the phone is the reader
The biggest adoption killer for a field payments app is hardware. If every staff member needs a dedicated card reader before they can take a dollar, most of them never will.
The Stripe Terminal React Native SDK solves this. On a supported iPhone, Tap to Pay turns the phone itself into a contactless reader. No dongle, no Bluetooth pairing, no extra device to charge and lose. A staff member installs the app and can accept a contactless card or a phone wallet right away.
The same SDK also drives physical readers when you want them. We support the BBPOS WisePOS E and the pocket-sized M2 over Bluetooth for teams that need chip and swipe, or just prefer a dedicated device. Both paths run through one integration. Your app code asks Terminal to collect a payment, and whether that is Tap to Pay or a paired reader is a connection detail rather than a second implementation.
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 different platforms with two different APIs and data models. To their staff, that distinction should be invisible. One app, one login, their data.
We solved it with a backend-for-frontend: a thin Node and Express API that the app talks to exclusively, which normalizes both upstream systems behind one consistent contract. When a user authenticates, the backend uses their session context to route each request to the correct upstream system.
This shape pays off twice:
- The mobile app stays simple about the backend. It renders records, catalogs, and payments from one predictable API, with no branching on "which system is this?" scattered through the UI.
- Divergence has one home. When the two platforms disagree, say a different field name or an endpoint one has and the other doesn't, you reconcile it in the BFF instead of the app. That is a much better place to keep the mess.
If you are integrating a mobile client against more than one system, or against an API you don't control, a BFF is usually worth the extra hop.
The payment flow that can't double-charge
This is the part that matters most, and it is easy to get wrong.
Picture a busy checkout flow. A staff member taps a customer's card. The charge succeeds at Stripe, but the app loses connection before it hears back. The staff member, seeing no confirmation, taps again. Without protection, that is two charges on a real person's card, in person, right now.
The fix is idempotency, anchored to Stripe's PaymentIntent. A PaymentIntent represents the intent to collect a specific amount, once. We create it up front and treat its ID as the identity of the transaction for its entire life. The backend endpoints that record a completed sale are keyed on 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
Retry that call as many times as the network makes you, and the customer is charged once and the sale is recorded once. We also persist the last successful PaymentIntent ID on the device, so a crash-and-relaunch resumes into a known state instead of starting a fresh charge.
Then we wrote tests around it. Payment integrity is exactly the kind of guarantee that quietly rots as a codebase grows, so we guard the never-double-charge invariant with automated regression tests. If a future change breaks it, CI fails instead of a customer at checkout.
Auth: OAuth with PKCE, tokens off the JS heap
Staff sign in through an OAuth 2.0 flow with PKCE, which fits a mobile app where you can't keep a client secret. The app stores session tokens in native secure storage, using Keychain or Keystore through Expo, instead of plain storage or app state. The backend uses the authenticated session context to route each user to the correct platform.
Takeaways
- Lead with Tap to Pay. Removing the hardware requirement is the single biggest thing you can do for adoption of a field payments app.
- One SDK, many capture methods. Stripe Terminal lets Tap to Pay and Bluetooth readers share one integration, so you build once.
- Put a BFF between your app and messy backends, especially when there is more than one or you don't own the API.
- Make payment recording idempotent and test it. Anchor everything to the PaymentIntent ID and guard the invariant in CI. This is the difference between an app staff trust and one they don't.
- Test on real devices from day one. The payment path you actually ship can't be validated in a simulator.
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.
