Stripe PaymentSheet subscriptions with Apple / Google Pay on Expo and Firebase

Let's put together a full, end-to-end Stripe subscription flow in our Expo app with Firebase.

Okay so I know the title is a bit of a mouthful but today's post is actually pretty neat - we're putting together a full, end-to-end Stripe subscription flow in our Expo app with Firebase. We're going to use PaymentSheet to handle the checkout experience because I prefer writing less code and Stripe's designers are awesome.

Basically, Stripe's new architecture (Setup Intents, Payment Intents and Payment Methods) are less instant than their previous counterparts (Tokens, Sources, etc). Because of increased security and fraud prevention through tools like 3D Secure (3DS), the API is a little different.

This means that instead of simply waiting to receive a response from the Stripe API, you're going to need to use their webhooks which will notify your backend when the payment has been processed successfully, or simply a different mental model towards approaching the checkout process.

Install the dependencies

We're going to need a couple of things to start:

Creating a seamless developer experience

The first thing we're going to do is write... a shell script! Yep seriously. Essentially what we're going to do is start up the Stripe CLI, Firebase Local Emulator Suite, Firebase Cloud Functions and Expo concurrently when running our app, so we only need 1 command to start our entire local environment.

Start by updating your package.json so it runs all this stuff simultaneously on yarn dev (or whatever command you prefer... I'm a Next.js lover):

"scripts": {
  "dev": "concurrently \"npm:start\" \"npm:emulators\" \"npm:watch\" \"npm:webhooks\"",
  "start": "expo start",
  "emulators": "cd functions && npm run dev",
  "watch": "cd functions && npm run watch",
  "webhooks": "./stripe.sh",
},

Next, create a small Bash script called stripe.sh:

if [ -f .env ]
then
  export $(cat .env | sed 's/#.*//g' | xargs)
  stripe listen --forward-to localhost:5001/$FIREBASE_PROJECT_ID/us-central1/webhooks
fi

This requires a FIREBASE_PROJECT_ID environment variable, preferable in a .env file.

Creating the Checkout screen

First up, you should follow all the documentation on Expo's Stripe API Reference to get your codebase up and running with native Stripe integration.

You should also check out the actual module and follow Stripe's usage instructions to get your StripeProvider and other bits set up.

Now, while the Checkout screen can contain anything you want, what we're going to need to begin is an asynchronous useEffect (or in my use, a nice useAsync by react-use) to fetch the parameters for our Payment Sheet and create our Payment Intent.

import useAsync from 'react-use/lib/useAsync';
import { Alert } from 'react-native';
import { useStripe } from '@stripe/stripe-react-native';
import fetchPaymentSheetParams from '../utils/stripe/fetchPaymentSheetParams'; // This is just a Firebase cloud function wrapper
 
const Checkout = () => {
  const customerId = 'customer id here';
  const { initPaymentSheet } = useStripe();
 
  useAsync(async () => {
    setLoading(true);
 
    try {
      const { paymentIntent, ephemeralKey } = await fetchPaymentSheetParams({
        customerId,
        quantity: 10,
      });
 
      if (!paymentIntent || !ephemeralKey) {
        throw new Error(
          'There was an error creating your payment sheet. Please try again.'
        );
      }
 
      const { error } = await initPaymentSheet({
        /*
         * This breaks in production for some reason lol
         * customerId,
         */
        customerEphemeralKeySecret: ephemeralKey,
        paymentIntentClientSecret: paymentIntent,
        merchantDisplayName: 'My App',
        applePay: true,
        googlePay: true,
        merchantCountryCode: 'US',
        testEnv: __DEV__,
      });
 
      if (error) {
        throw error as unknown as Error;
      }
 
      setLoading(false);
    } catch (error) {
      console.error(error);
      Alert.alert(
        'Error',
        'There was an error creating your payment sheet. Please try again.'
      );
    }
  }, [customerId, initPaymentSheet]);
};

Creating the Payment Sheet and Payment Intent

Next, we need to create a Firebase Cloud function. Most of the logic for this flow can be found by reading Stripe's Accept a payment guide for React Native, but I've simplified it here for you.

Also, in this instance, my subscriptions operate on a tiered pricing model which is why you'll see references to a "price".

Anyway, two things you'll want to pay careful attention to here:

The first is payment_behavior: 'default_incomplete'. This is a super interesting property that creates our subscription in an "incomplete" state i.e. it won't charge the user and isn't active. By tying this subscription to our Payment Intent, it will automatically activate upon successful completion of our Payment Intent's payment.

The second is expand: ['latest_invoice.payment_intent']. By default, Stripe subscriptions return a standard data set that doesn't include things like the latest invoice, however they do accept a "hydration" field of sorts called expand. This quite literally expands the subscription object with whatever you ask for, in this instance, the latest invoice and it's associated Payment Intent.

Here we go:

import type { FirebaseError } from 'firebase-admin';
import * as functions from 'firebase-functions';
 
type FetchPaymentSheetParamsProps = {
  customerId: string;
  quantity: number;
};
 
const stripe = new Stripe(process.env.STRIPE_SECRET, {
  apiVersion: '2020-08-27',
  typescript: true,
});
 
const fetchPaymentSheetParams = functions.https.onCall(
  async ({ customerId, quantity }: FetchPaymentSheetParamsProps) => {
    if (!customerId) {
      throw new functions.https.HttpsError(
        'invalid-argument',
        'The function must be called with "customerId" argument.'
      );
    }
 
    if (!quantity) {
      throw new functions.https.HttpsError(
        'invalid-argument',
        'The function must be called with "quantity" argument.'
      );
    }
 
    console.log(`Fetching payment sheet params for ${customerId}...`);
 
    try {
      const ephemeralKey = await stripe.ephemeralKeys.create(
        { customer: customerId },
        { apiVersion: '2018-11-08' }
      );
 
      const price = process.env.STRIPE_PRICE;
 
      console.log(
        `Creating inactive subscription with price ${price} for quantity ${quantity}...`
      );
 
      const subscription = await stripe.subscriptions.create({
        customer: customerId,
        items: [
          {
            price,
            quantity,
          },
        ],
        payment_behavior: 'default_incomplete',
        expand: ['latest_invoice.payment_intent'],
      });
 
      if (
        !subscription.latest_invoice ||
        typeof subscription.latest_invoice === 'string'
      ) {
        throw new Error(
          'Subscription was created without an invoice. Please contact support.'
        );
      }
 
      if (
        !subscription.latest_invoice.payment_intent ||
        typeof subscription.latest_invoice.payment_intent === 'string'
      ) {
        throw new Error(
          'Subscription was created without a payment intent. Please contact support.'
        );
      }
 
      return {
        paymentIntent: subscription.latest_invoice.payment_intent.client_secret,
        ephemeralKey: ephemeralKey.secret,
      };
    } catch (error) {
      console.error(error);
      throw new functions.https.HttpsError(
        'unknown',
        (error as FirebaseError).message
      );
    }
  }
);
 
export default fetchPaymentSheetParams;

Opening the PaymentSheet

Assuming our cloud function returned the right props, we're now up to adding a button that opens our PaymentSheet. This is a prebuilt Stripe UI component that handles everything for us, from parsing and validating credit cards to alternative payment methods, including Apple Pay and Google Pay like we enabled above.

What's really neat is that Stripe's Payment Sheet appears to handle 3DS for us. From my (limited) testing, I've found that test cards designed to trigger 3DS authentication are actually handled automatically before checkout is completed, which is awesome. Hope I'm right 😅

Anyway, here we go:

import { Alert } from 'react-native';
import { useStripe } from '@stripe/stripe-react-native';
 
const Checkout = () => {
  const [loading, setLoading] = useState<boolean>(false);
  const { presentPaymentSheet } = useStripe();
 
  // ...
 
  const openPaymentSheet = async () => {
    try {
      setLoading(true);
 
      const { error } = await presentPaymentSheet();
 
      if (error?.code === 'Canceled') {
        return;
      }
 
      if (error) {
        throw error as unknown as Error;
      }
 
      // You're done!
    } catch (error) {
      console.error(error);
      Alert.alert(
        'Error',
        "Something went wrong with the checkout process. Don't worry - your payment is safe. We will look into this ASAP."
      );
    } finally {
      setLoading(false);
    }
  };
 
  return (
    <Button disabled={loading} onPress={openPaymentSheet}>
      Subscribe
    </Button>
  );
};

Et voila! We now have a working Payment Sheet powered checkout process. Once the payment is made and the Payment Method is verified, the Payment Intent will complete and the subscription will start automatically.

This is all well and good if we're just kicking off a subscription, but if we need to execute some important success-driven code, we'll need to look elsewhere. As Stripe notes in their Payment Status Updates docs:

Your integration shouldn’t attempt to handle order fulfilment on the client side because it is possible for customers to leave the page after payment is complete but before the fulfilment process initiates. Instead, use webhooks to monitor the payment_intent.succeeded event and handle its completion asynchronously instead of attempting to initiate fulfilment on the client side.

So, let's implement some webhooks!

Listening for successful payments

We'll start by creating a new file called webhooks.ts (or whatever you want to call it, rebel). This is our single entry point for all Stripe-related webhooks. From here, we can run functions based on the type of incoming event. Let's start with payment_intent.succeeded:

import * as functions from 'firebase-functions';
import Stripe from 'stripe';
import confirmSubscription from './confirmSubscription';
 
const webhooks = functions.https.onRequest(async (req, res) => {
  const body: Stripe.Event = req.body;
 
  if (body.type === 'payment_intent.succeeded') {
    console.log('Processing payment intent succeeded event...');
    await confirmSubscription(body);
    res.status(200).send('OK');
    return;
  }
 
  // You can add other Stripe events here
 
  res.status(400).send('Unknown event type');
  return;
});
 
export default webhooks;

Now we can write specific code for successful Payment Intents and infer the body is a Stripe PaymentIntent.

import Stripe from 'stripe';
 
const confirmSubscription = async (event: Stripe.Event) => {
  const invoice = event.data.object as Stripe.PaymentIntent;
 
  // Do what you need to here
};
 
export default confirmSubscription;

And that's it! The running Stripe CLI instance will forward all events to your webhook endpoint in your local Firebase emulator, so you get a full end-to-end workflow.

Let me know what you think!


Published on January 29, 2022 • 8 min read