Engineering article

Adding Tap to Pay (NFC Payments) in a React Native App using Stripe Terminal

Tap to Pay on phone using NFC illustration

In this article, we’ll walk through the end-to-end implementation of Tap‑to‑Pay in an existing React Native CLI project using Stripe Terminal.

What we are building

The goal is simple: allow a merchant to accept payments by letting customers tap their card on the device.

The payment can be made using:

  • Physical debit cards
  • Physical credit cards
  • Contactless NFC cards

With Tap‑to‑Pay on iPhone, merchants can accept contactless payments directly from the iPhone without requiring additional hardware. The feature relies on the device’s NFC chip and secure payment infrastructure to process transactions safely.

Before moving forward, keep these points in mind:

  • The SDK uses TypeScript features available in Babel version 7.9.0 and above.
  • Android API level 26 and above.
  • compileSdkVersion = 35 and targetSdkVersion = 35.
  • Compatible with apps targeting iOS 15.1 or above.

1. Packages used

We’ll use the following packages:

  • @stripe/stripe-react-native
  • @stripe/stripe-terminal-react-native
  • react-native-nfc-manager

Example versions from package.json:

"@stripe/stripe-react-native": "0.50.3",
"@stripe/stripe-terminal-react-native": "0.0.1-beta.26",
"react-native-nfc-manager": "3.16.3",
"react-native": "0.81.5"

2. Required device permissions

iOS permissions

Add the following keys to your Info.plist:

  • NSLocationWhenInUseUsageDescription
  • NSBluetoothAlwaysUsageDescription
  • NSBluetoothPeripheralUsageDescription
  • UIBackgroundModes with value bluetooth-central

Android permissions

Add the following permissions:

  • PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT
  • PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN
  • PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION

These permissions allow the app to discover and connect to nearby readers.

3. iOS setup — Tap to Pay on iPhone

Apple requires special approval to enable Tap‑to‑Pay. Follow Apple’s official “Setting up Tap to Pay” documentation and then:

  1. Request Tap‑to‑Pay entitlement from Apple Developer.
  2. Go to https://developer.apple.com/account/resources/identifiers, select your App ID and under Capabilities enable NFC Reading and Tap to Pay on iPhone.
  3. Use the Tap‑to‑Pay request form at https://developer.apple.com/contact/request/tap-to-pay-on-iphone/. You need Admin access to submit it.
  4. Create a new provisioning profile for the updated App ID and download it.

Configure the entitlement in your app

  1. Create a new entitlements file in Xcode (for example, your_project.entitlements).
  2. Set the Code Signing Entitlements build setting to point to that file.
  3. Open the entitlements file and add the key com.apple.developer.proximity-reader.payment.acceptance with a Boolean value of true:
<key>com.apple.developer.proximity-reader.payment.acceptance</key>
<true/>

Tap‑to‑Pay requires an iPhone XS or later and a supported iOS version.

4. Create Stripe connection token

Stripe Terminal requires a short‑lived connection token, generated from your backend using the Stripe Secret Key.

Example cURL:

curl https://api.stripe.com/v1/terminal/connection_tokens \
  -u sk_test_xxx: \
  -X POST

Your backend should expose an endpoint such as:

POST /connection_token

5. Fetch token on the frontend

const fetchTokenProvider = async () => {
  const response = await fetch(`${API_URL}/connection_token`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
  });

  const { secret } = await response.json();
  return secret;
};

6. Initialize Stripe Terminal and SDK

import React, { useEffect } from "react";
import {
  StripeTerminalProvider,
  useStripeTerminal,
} from "@stripe/stripe-terminal-react-native";

function App() {
  const { initialize } = useStripeTerminal();

  // Call initialize on mount
  useEffect(() => {
    initialize();
  }, [initialize]);

  return (
    <StripeTerminalProvider
      logLevel="verbose"
      tokenProvider={fetchTokenProvider}
    >
      <AppNavigator />
    </StripeTerminalProvider>
  );
}

7. Custom hook for Tap‑to‑Pay logic

To keep the screen clean, we move the payment logic into a custom hook, useTapAndPayAction:

import {
  PaymentIntent,
  Reader,
  requestNeededAndroidPermissions,
  useStripeTerminal,
} from "@stripe/stripe-terminal-react-native";
import { useEffect, useState } from "react";
import NfcManager from "react-native-nfc-manager";

NfcManager.start();

export const useTapAndPayAction = () => {
  const [payment, setPayment] = useState();
  const [paymentProcessing, setPaymentProcessing] = useState(true);
  const [locationIdText, setLocationIdText] = useState("");

  const {
    isInitialized,
    initialize,
    discoverReaders,
    connectReader,
    disconnectReader,
    cancelDiscovering,
    retrievePaymentIntent,
    collectPaymentMethod,
    confirmPaymentIntent,
  } = useStripeTerminal({
    onUpdateDiscoveredReaders: (readers) => {
      handleConnectReader(readers[0]);
    },
  });

  useEffect(() => {
    checkNfcSupport();
  }, []);

  const checkNfcSupport = async () => {
    const supported = await NfcManager.isSupported();
    const enabled = await NfcManager.isEnabled();

    console.log("supported =>", supported);
    console.log("enabled =>", enabled);
  };

  useEffect(() => {
    initialize();
  }, []);

  useEffect(() => {
    fetchReaders();
  }, [isInitialized]);

  const fetchReaders = async () => {
    if (isInitialized) {
      await discoverReaders({
        discoveryMethod: "tapToPay",
        simulated: false,
      });
    }
  };

  async function handleConnectReader(selectedReader) {
    const { reader, error } = await connectReader(
      {
        reader: selectedReader,
        locationId: locationIdText,
        autoReconnectOnUnexpectedDisconnect: true,
      },
      "tapToPay"
    );

    _fetchPaymentIntent();
  }

  async function _fetchPaymentIntent() {
    const { paymentIntent } = await retrievePaymentIntent(
      paymentData.paymentIntentData.client_secret
    );

    setPayment(paymentIntent);
  }

  async function collectPayment(pi) {
    const { paymentIntent } = await collectPaymentMethod({
      paymentIntent: pi,
    });

    _confirmPaymentIntent(paymentIntent);
  }

  const _confirmPaymentIntent = async (collectedPaymentIntent) => {
    const { paymentIntent } = await confirmPaymentIntent({
      paymentIntent: collectedPaymentIntent,
    });

    if (paymentIntent?.status === "succeeded") {
      console.log("Payment successful");
    }
  };

  return {
    paymentProcessing,
    payment,
    collectPayment,
  };
};

8. Using the hook in a screen

const TapAndPayScreen = () => {
  const {
    paymentProcessing,
    payment,
    collectPayment,
  } = useTapAndPayAction();

  const _handleTapToPayClick = () => {
    collectPayment(payment);
  };

  return (
    <View>
      {/* Other components */}
      <Button
        title="Tap To Pay"
        onPress={_handleTapToPayClick}
      />
    </View>
  );
};

Wrap‑up

Congratulations! Your React Native app now supports Tap to Pay using Stripe Terminal and NFC. With the right permissions, entitlements, and Stripe configuration, you can provide a hardware‑free contactless payment experience to your merchants.

Share your thoughts

Have questions or ideas about Tap to Pay or Stripe Terminal? Leave a comment below.