Skip to main content

React Native Stripe: Solving 'Ephemeral Key' & PaymentSheet Initialization Errors

 Few things in mobile development are as frustrating as a silent failure in a payments flow. You have followed the Stripe documentation, installed @stripe/stripe-react-native, and set up your backend endpoint. Yet, when you call initPaymentSheet, nothing happens, or you receive a cryptic error stating that the payment information could not be found.

The culprit is almost always a mismatch in the "Holy Trinity" of the PaymentSheet: the Customer ID, the PaymentIntent, and the Ephemeral Key.

This guide dissects the root cause of these initialization errors, explains the strict relationship required between these three entities, and provides a production-ready implementation for both your Node.js backend and React Native frontend.

The Root Cause: The Authorization Triangle

To render a saved card or allow a user to save a new one, Stripe's mobile SDK requires a temporary "pass" to modify the customer's data directly from the device. This is the Ephemeral Key.

The error occurs when the relationship between these three objects is broken:

  1. The Customer: The persistent user record in Stripe.
  2. The PaymentIntent: The specific transaction attempting to charge that Customer.
  3. The Ephemeral Key: A temporary API key scoped strictly to that Customer.

Why It Fails

The most common failure scenario is an Identity Mismatch. This happens if the PaymentIntent was created for Customer A (or no customer at all), but the EphemeralKey was generated for Customer B.

The second most common failure is API Versioning. The React Native SDK expects the Ephemeral Key to be generated using a specific Stripe API version. If your backend defaults to an older global API version and does not explicitly override it during key generation, the JSON structure sent to the phone will differ from what the SDK expects, causing initialization to fail.

The Solution: Backend Implementation (Node.js/TypeScript)

We must ensure that the PaymentIntent and EphemeralKey reference the exact same Customer ID. Furthermore, we must explicitly enforce the API version during key generation.

Here is a robust Express.js handler using the official stripe library.

import express, { Request, Response } from 'express';
import Stripe from 'stripe';

const router = express.Router();

// Initialize Stripe with your Secret Key
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2023-10-16', // Ensure your server uses a modern default
});

router.post('/payment-sheet', async (req: Request, res: Response) => {
  try {
    // 1. Identify the customer (In production, get this from your Auth middleware)
    // For this example, we assume we either have a Stripe ID or create a new one.
    let customerId = req.body.stripeCustomerId;

    if (!customerId) {
      const customer = await stripe.customers.create();
      customerId = customer.id;
    }

    // 2. Create the Ephemeral Key
    // CRITICAL: You must pass the `apiVersion` parameter.
    // This tells Stripe to generate a key compatible with the mobile SDK's expected schema.
    const ephemeralKey = await stripe.ephemeralKeys.create(
      { customer: customerId },
      { apiVersion: '2023-10-16' } 
    );

    // 3. Create the PaymentIntent
    // CRITICAL: This MUST be attached to the same customerId used above.
    const paymentIntent = await stripe.paymentIntents.create({
      amount: 5099, // $50.99 (amount in smallest currency unit)
      currency: 'usd',
      customer: customerId,
      automatic_payment_methods: {
        enabled: true,
      },
    });

    // 4. Return the strict set of secrets to the client
    res.json({
      paymentIntent: paymentIntent.client_secret,
      ephemeralKey: ephemeralKey.secret,
      customer: customerId,
      publishableKey: process.env.STRIPE_PUBLISHABLE_KEY
    });

  } catch (error: any) {
    console.error('Stripe Error:', error.message);
    res.status(500).send({ error: error.message });
  }
});

export default router;

The Solution: Frontend Implementation (React Native)

On the client side, we use the useStripe hook. The goal is to fetch the keys from the backend and pass them into initPaymentSheet immediately.

Do not attempt to present the sheet until initialization resolves successfully.

import React, { useEffect, useState } from 'react';
import { View, Button, Alert, ActivityIndicator, StyleSheet } from 'react-native';
import { useStripe } from '@stripe/stripe-react-native';

const API_URL = 'https://your-api-endpoint.com/payment-sheet';

export default function CheckoutScreen() {
  const { initPaymentSheet, presentPaymentSheet } = useStripe();
  const [loading, setLoading] = useState(true);

  const fetchPaymentParams = async () => {
    try {
      const response = await fetch(API_URL, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        // In a real app, send the user's ID to look up their Stripe ID
        body: JSON.stringify({ userId: 'user_123' }), 
      });
      
      const data = await response.json();
      
      if (!response.ok) {
        throw new Error(data.error || 'Network response was not ok');
      }
      
      return {
        paymentIntent: data.paymentIntent,
        ephemeralKey: data.ephemeralKey,
        customer: data.customer,
      };
    } catch (error) {
      console.error("Failed to fetch payment params", error);
      return null;
    }
  };

  const initializePaymentSheet = async () => {
    const params = await fetchPaymentParams();

    if (!params) {
      setLoading(false);
      Alert.alert('Error', 'Could not fetch payment configuration');
      return;
    }

    const { error } = await initPaymentSheet({
      merchantDisplayName: 'Your App Name, Inc.',
      customerId: params.customer,
      customerEphemeralKeySecret: params.ephemeralKey,
      paymentIntentClientSecret: params.paymentIntent,
      // Optional: styling customizations
      appearance: {
        colors: {
          primary: '#000000',
        },
      },
      // Essential for returning users to see their saved cards
      allowsDelayedPaymentMethods: true, 
    });

    if (error) {
      console.log('Init error:', error);
      Alert.alert('Error', error.message);
    } else {
      setLoading(false);
    }
  };

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

  const openPaymentSheet = async () => {
    // presentPaymentSheet actually opens the UI
    const { error } = await presentPaymentSheet();

    if (error) {
      Alert.alert(`Error code: ${error.code}`, error.message);
    } else {
      Alert.alert('Success', 'Your order is confirmed!');
    }
  };

  return (
    <View style={styles.container}>
      {loading ? (
        <ActivityIndicator size="large" color="#0000ff" />
      ) : (
        <Button 
            title="Checkout" 
            onPress={openPaymentSheet} 
            disabled={loading} 
        />
      )}
    </View>
  );
}

const styles = StyleSheet.create({
    container: {
        flex: 1,
        justifyContent: 'center',
        alignItems: 'center'
    }
});

Deep Dive: Why This Fix Works

The customer Mismatch

In the code above, the paymentIntents.create call explicitly includes customer: customerId. If you omit this line on the backend, the PaymentIntent is "Guest" mode. However, if you then pass a customerId and ephemeralKey to the frontend initPaymentSheet, the SDK detects a conflict. It cannot save a card to a customer that isn't attached to the active transaction.

By creating the ephemeralKeys and paymentIntents in the same scope using the same customerId variable, we guarantee referential integrity.

The API Version Header

Notice the second argument in stripe.ephemeralKeys.create{ apiVersion: '2023-10-16' }.

Stripe updates its API frequently. The @stripe/stripe-react-native library is built against a specific version of the Stripe API objects. If your backend account default is set to 2020-08-27 (an old version), the ephemeral key JSON returned will lack specific fields expected by the modern mobile SDK. Hardcoding the version in the creation call overrides your account default for that specific request, ensuring compatibility without breaking other parts of your backend.

Common Pitfalls and Edge Cases

1. Hardcoded Currencies

While we used 'usd' in the example, ensure the currency matches the user's region or your platform settings. A mismatch between the PaymentIntent currency and the merchant's capabilities can cause initialization errors.

2. Merchant Display Name

The merchantDisplayName property in initPaymentSheet is mandatory. Failing to provide this string usually results in an immediate crash or an "Unknown Error" on iOS.

3. Asynchronous State Management

A common React Native mistake is calling presentPaymentSheet immediately after initPaymentSheet without await. Initialization is an asynchronous network operation (it communicates with Stripe servers to validate the keys). If you try to present before initialization completes, the sheet will fail to open. Always manage this with a loading state or useEffect dependency chain.

Conclusion

The Stripe PaymentSheet is a powerful tool for maintaining PCI compliance and reducing UI work, but it requires strict backend-frontend synchronization. By ensuring your PaymentIntent and EphemeralKey share the exact same customerId, and enforcing the correct API version during key generation, you eliminate the most common sources of initialization failures.