Stripe React Native with Expo (2026): PaymentSheet, Apple Pay, and Google Pay
Add Stripe to a React Native Expo app in 2026 with PaymentSheet, Apple Pay, Google Pay, SetupIntent, and 3D Secure. Full config-plugin setup, testing, and webhook confirmation.
To add Stripe to a React Native Expo app in 2026, install @stripe/stripe-react-native, enable its Expo config plugin in app.json, wrap your root layout in <StripeProvider>, then call useStripe().initPaymentSheet() and presentPaymentSheet() to collect a card, Apple Pay, or Google Pay payment against a server-created PaymentIntent. The workflow needs an EAS development build (Expo Go can't load Stripe's native code), and it takes roughly 30 minutes end to end once your Stripe test keys are ready.
Stripe's official @stripe/stripe-react-native ships an Expo config plugin, so no manual iOS or Android edits are required. You do have to run expo prebuild or an EAS build though; Expo Go won't work.
PaymentSheet is the recommended flow in 2026: one native modal that handles cards, Apple Pay, Google Pay, Link, iDEAL, and 30+ local methods with a single API call.
Apple Pay needs a merchant ID registered in the Apple Developer portal, plus merchantIdentifier in the config plugin. Google Pay just needs googlePay: { enabled: true } on <StripeProvider>.
SetupIntent (not PaymentIntent) is the correct primitive for saving a card off-session, for subscriptions, invoicing, or one-tap re-purchase.
3D Secure 2 authentication is handled automatically by PaymentSheet since SDK v0.42. You only need to handle the requires_action status if you build a custom UI.
Always confirm the payment server-side via a Stripe webhook (payment_intent.succeeded). Never trust the client-side paymentIntent.status alone to unlock content.
What is @stripe/stripe-react-native?
@stripe/stripe-react-native is Stripe's official React Native SDK, built on top of the native stripe-ios and stripe-android libraries. In 2026 it sits at v0.45, and it's the only Stripe-supported way to accept in-app payments outside of the Stripe Terminal SDK. It exposes React hooks for PaymentSheet, PlatformPay (Apple Pay / Google Pay), CardField, AddressSheet, and the low-level PaymentIntent APIs. Unlike community wrappers around Stripe.js, it's fully native, which matters for PCI scope (raw card data never touches your JavaScript bundle) and for Apple's App Store review, which requires native payment sheets for any real-world goods flow.
The SDK ships an Expo config plugin, which means you can adopt it on a managed Expo project without ejecting. Instead of editing Info.plist, AppDelegate.swift, or your AndroidManifest.xml by hand, you list the plugin in app.json and let expo prebuild (or the EAS build service) inject the correct native project changes. That workflow is identical to what we walk through in our Expo config plugins guide, and it means Stripe integrations survive future Expo SDK upgrades without merge conflicts in native code.
How do you add Stripe to a React Native Expo app?
So, you add Stripe to a React Native Expo project in five steps: install the SDK, register the config plugin, run a prebuild, wrap the root component in <StripeProvider>, and start a development build. (Expo Go will crash at runtime because it doesn't bundle Stripe's native code.) Here's the full setup.
1. Install the package
npx expo install @stripe/stripe-react-native
Using expo install instead of raw npm/yarn locks the version to whatever is compatible with your current Expo SDK. On SDK 55 that resolves to v0.45.x; on SDK 54 it pins to v0.42.x. Don't mix versions across a monorepo, because the native module ABI changes between minor releases. I hit that once in a Yarn workspaces setup and spent an afternoon chasing a "duplicate symbols" link error before I noticed.
The merchantIdentifier is your Apple Pay merchant ID (created in the Apple Developer portal, format merchant.<reverse-dns>). If you skip it now you can add Apple Pay later, but you'll need to run prebuild again. enableGooglePay writes the required <meta-data> tag into AndroidManifest.xml.
3. Prebuild the native projects
npx expo prebuild --clean
Then create a development build with eas build --profile development --platform all, or run npx expo run:ios / npx expo run:android locally. From this point forward you launch the app via the dev-client build, not Expo Go.
4. Wrap the root layout in StripeProvider
// app/_layout.tsx
import { StripeProvider } from "@stripe/stripe-react-native";
import { Stack } from "expo-router";
export default function RootLayout() {
return (
<StripeProvider
publishableKey={process.env.EXPO_PUBLIC_STRIPE_PK!}
merchantIdentifier="merchant.com.example.checkout"
urlScheme="checkoutdemo" // for 3DS redirects
>
<Stack />
</StripeProvider>
);
}
Only the publishable key (pk_test_... or pk_live_...) belongs in the client. Never bundle the secret key. That stays on your server, which we cover in the webhooks section below.
Building a PaymentSheet checkout in 2026
PaymentSheet is Stripe's prebuilt mobile checkout UI. It renders as a native bottom sheet (UIKit on iOS, Material 3 on Android), auto-selects the right payment methods per country, and handles 3D Secure, address collection, Link, and saved cards without any custom UI code. It's the flow Stripe now recommends for 95% of React Native apps. The alternative CardField component still works, but you take on PCI scope, keyboard handling, and error UX yourself.
Honestly, a PaymentSheet checkout is a two-hop dance. Your server creates a PaymentIntent and returns its client_secret, an ephemeralKey, and a customer id. Your app passes those to initPaymentSheet, then calls presentPaymentSheet when the user taps the Pay button.
// components/CheckoutButton.tsx
import { useState } from "react";
import { Button, Alert } from "react-native";
import { useStripe } from "@stripe/stripe-react-native";
export function CheckoutButton({ amount }: { amount: number }) {
const { initPaymentSheet, presentPaymentSheet } = useStripe();
const [loading, setLoading] = useState(false);
const openSheet = async () => {
setLoading(true);
// 1. Ask your backend to create a PaymentIntent
const res = await fetch("https://api.example.com/payment-sheet", {
method: "POST",
body: JSON.stringify({ amount, currency: "usd" }),
});
const { paymentIntent, ephemeralKey, customer } = await res.json();
// 2. Initialise PaymentSheet with the returned secrets
const init = await initPaymentSheet({
merchantDisplayName: "Checkout Demo, Inc.",
customerId: customer,
customerEphemeralKeySecret: ephemeralKey,
paymentIntentClientSecret: paymentIntent,
applePay: { merchantCountryCode: "US" },
googlePay: { merchantCountryCode: "US", testEnv: true },
allowsDelayedPaymentMethods: true,
returnURL: "checkoutdemo://stripe-redirect",
});
if (init.error) { setLoading(false); return Alert.alert(init.error.message); }
// 3. Present the native modal
const { error } = await presentPaymentSheet();
setLoading(false);
if (error) Alert.alert(`${error.code}`, error.message);
else Alert.alert("Success", "Your order is confirmed.");
};
return <Button title="Pay" onPress={openSheet} disabled={loading} />;
}
The returnURL is required as of SDK v0.42 because 3D Secure and some redirect-based methods (iDEAL, Bancontact) bounce out to a browser and back. It must match a scheme declared in app.json under scheme. If you skip it, PaymentSheet still works for plain cards but silently disables redirect methods. That's a common cause of "why does iDEAL not show up" support tickets, and I've seen it trip up at least three teams shipping to European users.
How to add Apple Pay to a React Native app
Adding Apple Pay to a React Native app takes three pieces: an Apple Pay merchant ID, the Apple Pay entitlement in your Xcode project (added automatically by the Stripe config plugin), and a merchantCountryCode passed to initPaymentSheet. Once those are in place, PaymentSheet shows the Apple Pay button natively. You don't render your own button unless you want the standalone PlatformPayButton component for a one-tap flow.
Create the merchant ID
In the Apple Developer portal, go to Certificates, Identifiers & Profiles → Identifiers → Merchant IDs and add a new one such as merchant.com.example.checkout. Then, in the Stripe Dashboard under Settings → Payments → Payment methods → Apple Pay, add the same merchant ID and download the CSR that Stripe generates. Upload the CSR back to Apple to obtain the merchant certificate, then upload that certificate to Stripe. This handshake tells Apple that Stripe is authorised to decrypt tokens for your merchant ID.
Use the standalone Apple Pay button
If you want an Apple Pay button outside PaymentSheet (say, on a product detail page for one-tap checkout), use PlatformPayButton:
Apple's Human Interface Guidelines require you to use their button component. Don't draw your own Apple Pay button with the wordmark, or your app will get rejected on review. (Yes, that's happened to me on a client project. The reviewer was very specific about it.)
How to add Google Pay to a React Native app
Google Pay is a lot simpler than Apple Pay because there's no merchant certificate handshake. Google issues a payment token signed with Stripe's gateway ID (stripe:acct_xxx) at transaction time. All you need is the Google Play Services Wallet on the device and the plugin flag we set earlier.
await initPaymentSheet({
// ...
googlePay: {
merchantCountryCode: "US",
currencyCode: "USD",
testEnv: __DEV__, // uses Google Pay test cards while true
},
});
For a standalone Google Pay button, mirror the Apple Pay pattern:
The same component switches between Apple Pay (iOS) and Google Pay (Android) at runtime, so you don't need Platform.OS branching for the button itself. You do need it for the confirm call, since applePay and googlePay keys are platform-specific.
Saving cards with SetupIntent
Use SetupIntent, not PaymentIntent, whenever you want to charge a card later without the user present. Think recurring subscriptions, invoicing, buy-now-charge-later, or one-tap re-purchase. A SetupIntent goes through the same 3D Secure challenge as a payment but authorises the card for future off-session use rather than charging it immediately. Getting this wrong is honestly the single most common Stripe integration bug I see in React Native codebases: teams save a PaymentMethod attached to a fully-completed PaymentIntent and then get blocked by the customer's bank on the first re-charge because SCA was never established.
When you later charge the saved card from your server, pass off_session: true and confirm: true to paymentIntents.create. If the bank rejects the charge with an authentication_required error, notify the user (push notification or email) and re-present PaymentSheet with the failed PaymentIntent's client secret so they can complete a fresh 3DS challenge.
Storing the Stripe customer id against your local user record (in AsyncStorage, MMKV, or a server DB) is a natural companion to this flow. If you're looking for a fast client-side store, our comparison of MMKV vs AsyncStorage covers the trade-offs, though for anything payment-related the source of truth should always live on your backend.
3D Secure, SCA, and payment authentication
3D Secure 2 (3DS2) is a challenge protocol required in the EU, UK, India, and increasingly elsewhere under Strong Customer Authentication (SCA) rules. When PaymentSheet detects that a card requires 3DS, it opens an in-app browser to the card issuer's challenge page, waits for the user to complete biometric or SMS verification, and returns to your app via the returnURL you registered on StripeProvider.
As of @stripe/stripe-react-native v0.42, this is fully automatic inside PaymentSheet. You don't need to handle next_action or handleNextAction manually. The only requirement is a valid returnURL matching your app's scheme. If you skip it, the challenge sheet can't deep-link back and users get stuck on a blank Safari tab. On Android, the URL is opened in a Chrome Custom Tab and the app resumes via the intent filter that the config plugin adds to your AndroidManifest.xml.
If you're building a custom card form with CardField instead of PaymentSheet, you need to inspect the returned status:
const { paymentIntent, error } = await confirmPayment(clientSecret, {
paymentMethodType: "Card",
});
if (paymentIntent?.status === "RequiresAction") {
const next = await handleNextAction(clientSecret);
if (next.error) console.warn(next.error);
}
Don't attempt to render your own 3DS challenge WebView. The card networks require the challenge to run in a browser context that they can identify (User-Agent, cookies, iframe boundaries), and a bare WebView will fail EMV 3DS2 fingerprinting so the transaction will decline. If you must customise the browser experience, our React Native WebView guide explains the constraints, but the short version is: let PaymentSheet or handleNextAction do the work.
Testing Stripe payments in React Native
Stripe test mode is fully wired into the React Native SDK. You switch modes purely by swapping the publishable key on StripeProvider (pk_test_... vs pk_live_...) and the matching secret key on your server. In test mode, real charges don't settle; instead, specific test card numbers trigger deterministic scenarios that you can rely on in CI. The canonical numbers to bookmark:
Scenario
Card number
Result
Successful Visa
4242 4242 4242 4242
Payment succeeds, no 3DS
3DS2 challenge required
4000 0027 6000 3184
Opens 3DS challenge sheet
Declined (generic)
4000 0000 0000 9995
Error card_declined
Insufficient funds
4000 0000 0000 9995
Error insufficient_funds
Fraudulent
4100 0000 0000 0019
Blocked by Radar
SetupIntent then off-session decline
4000 0000 0000 3220
Requires re-authentication
Any three-digit CVC and any future expiry date will work. For end-to-end automation, I run PaymentSheet flows in Maestro; the native bottom sheet responds to tapOn: "4242" selectors just like any UIKit view. Our guide on testing React Native apps with Maestro has a full flow for driving PaymentSheet in CI. Local iOS simulator note: Apple Pay always uses the built-in Apple Pay Test Cards from Wallet, so you can't enter Stripe test numbers there. Stripe still treats the resulting token as a test-mode charge because your keys are test keys.
Webhooks and server-side confirmation
Never grant access to a paid feature purely from the client-side success callback. Users can drop network mid-transaction, cancel from the App Store parental controls flow, or dispute the charge minutes later. The authoritative signal is a Stripe webhook, an HTTPS POST from Stripe to your backend when the payment state changes. For a standard PaymentSheet flow, the events to listen for are payment_intent.succeeded, payment_intent.payment_failed, and setup_intent.succeeded. For subscriptions, add invoice.paid and customer.subscription.updated.
// Node.js Express handler
import express from "express";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SK!);
const app = express();
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.headers["stripe-signature"] as string;
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (err) {
return res.status(400).send(`Webhook signature failure`);
}
if (event.type === "payment_intent.succeeded") {
const pi = event.data.object as Stripe.PaymentIntent;
// fulfilOrder(pi.metadata.orderId)
}
res.json({ received: true });
}
);
Test webhooks locally with the Stripe CLI: stripe listen --forward-to localhost:3000/webhooks/stripe. This gives you a temporary whsec_... secret that signs events as if they came from production, which is essential for local development on Expo dev clients that hit a laptop-hosted API. See the Stripe webhooks documentation for the full event catalogue.
Two more references worth bookmarking: the stripe-react-native GitHub repo is where API changes and known bugs surface first (issues get answered pretty fast), and the accept-a-payment React Native quickstart from Stripe's official docs mirrors this article's PaymentSheet flow with additional back-end language samples (Python, Go, Ruby, PHP).
Frequently Asked Questions
Does Stripe work with Expo Go?
No. @stripe/stripe-react-native contains native iOS and Android code that Expo Go doesn't bundle. You must use an EAS development build or a local expo run:ios / expo run:android build. This applies to every Stripe SDK feature, including PaymentSheet, Apple Pay, and CardField.
Do I need to eject from Expo to use Stripe?
No. The Stripe React Native SDK ships an Expo config plugin, so a managed workflow project can add Stripe by listing the plugin in app.json and running expo prebuild. You never touch Xcode or Android Studio unless you want to. You stay on the Continuous Native Generation model.
What is the difference between PaymentIntent and SetupIntent?
A PaymentIntent represents a charge you are collecting now. A SetupIntent authorises a payment method for future off-session use (subscriptions, saved-card re-purchase, invoicing) without charging anything at setup time. Use SetupIntent whenever the user is not standing at checkout when you take the money.
Can I use Stripe for in-app purchases of digital goods on iOS?
Generally no. Apple's App Store guideline 3.1.1 requires digital goods to use StoreKit / IAP. Stripe is the right tool for physical goods, services, event tickets, and business SaaS billed outside the app. For consumable digital purchases and subscriptions to app-only features, use RevenueCat or expo-iap instead.
Why is Apple Pay not showing in my PaymentSheet?
The three usual causes are (1) missing merchantIdentifier in the Stripe config plugin, (2) merchant ID not registered on both Apple Developer and Stripe Dashboard sides, or (3) running on an iOS simulator with no card added to the Wallet app. Open Settings → Wallet in the simulator and add an Apple Pay test card.
Is @stripe/stripe-react-native compatible with the New Architecture?
Yes. The SDK has full Fabric and TurboModules support since v0.38, and it's on by default in projects created with Expo SDK 55 or newer. If you upgraded an older project, check that newArchEnabled: true is set in app.json under both ios and android.