Stripe Payments with React and headless Magento 🚀

October 25, 2024

  • Frontend

  • Backend

  • Payment

  • Magento

  • React

  • Stripe

Photo of people shopping online

In this article we're going to focus on setting up a front-end application to handle credit card payment with Stripe and achieve a seamless online shopping experience.

Software prerequisites:

  • Magento Open Source 2.4.x,
  • Stripe Payments 4.x - module for Magento,
  • React application (mine was based on Next.js 14.x).

What you already did:

  • configured your Stripe account,
  • installed and configured Stripe Payments module for your Magento instance.

Initialize the Cart

Before an user adds a product to the cart, we need to create the cart first in Magento. e.g. right after we initialize the application.

POST /rest/V1/guest-carts

The endpoint is going to return the cart ID. We need to store it somewhere to make it persistent, e.g. in localStorage. There are a couple of state-management libraries which let you store persistent state in browser's inbuilt storages (e.g. zustand).

💡 In Magento cart and quote are called interchangeably and they're basically the same object

Then the user can add products to the cart.

POST /rest/V1/guest-carts/${cartId}/items
{
    cartItem: {
        quote_id: cartId,
        sku,
        qty,
        product_option, // For composite products
    }
}

Checkout component

To put it simply, your checkout probably consists of steps and looks more-less like below. For the needs of this article, the following four-step component illustrates what actions an user needs to take to reach credit card details form.

const Checkout: React.FC = () => (
    <>
        <main>
            <Steps>
                <ShippingMethod />
                <ShippingDetails />
                <BillingDetails />
                <Payment />
            </Steps>
        </main>
        <aside>
            <Cart />
        </aside>
    </>
);

💡 This article is mostly about Stripe Payments. I skip some obvious logic to keep the article short and simple e.g. I skip logic related to showing processing state indicator, validating or disabling the forms when making asynchronous requests

Shipping Method and Shipping Address

As the user already added something to cart, let's fetch available shipping methods for the country.

POST /rest/${storeCode}/V1/guest-carts/${cartId}/estimate-shipping-methods
{
    address: {
        countryId
    }
}

As we collected the shipping method and the shipping address, we can store them in Magento.

POST /rest/${storeCode}/V1/guest-carts/${cartId}/shipping-information
{
    addressInformation: {
        shippingAddress,
        shipping_carrier_code,
        shipping_method_code,
    }
}

You should receive available payment methods in response from the above endpoint.

💡 If your store allows to choose from multiple countries for the delivery, you can do the other way around - first collect the shipping address and then fetch available shipping methods

Billing Address and Payment

At this point, the user already added a product to the cart, selected a shipping method and filled in the shipping address. In this step he or she is going to choose one from available payment methods (Credit Card in this case), fill in the billing address and credit card details.

GET /rest/V1/guest-carts/${cartId}/totals

Make sure you have got recent totals information - you can use the same data which got fetched for presentation purposes in the Cart component. We need to pass the grand total to Stripe.

Payment elements

At this point the user already filled in the billing address and selected online payment as the payment method. Stripe offers multiple ways to handle the payment UI - in this guide I will be using @stripe/react-stripe-js package.

import type React from 'react';
import { Elements, useElements, useStripe } from '@stripe/react-stripe-js';
import { loadStripe } from '@stripe/stripe-js';
import { stripePublicApiKey, locale } from '@adam/StripeConfig';
import StripePaymentHandler from '@adam/StripePaymentHandler';

type StripePaymentProps = {
    grandTotal: number;
    billingName: string;
    currency: string;
};

// Make sure to call `loadStripe` outside of a component's render to avoid
// recreating the `Stripe` object on every render.
const stripePromise = loadStripe(stripePublicApiKey, { locale });

const StripePayment: React.FC<StripePaymentProps> = ({ grandTotal, billingName, currency }) => (
    <Elements
        stripe={stripePromise}
        options={{
            mode: 'payment',
            currency,
            // Amount must be lowest currency unit, e.g. $100 = 10000
            amount: Math.ceil(grandTotal * 100),
            locale,
            paymentMethodCreation: 'manual',
            payment_method_types: ['card'],
            // You can add "appearance" property to change look and feel of the credit card form
        }}
    >
        <StripePaymentHandler billingName={billingName} />
    </Elements>
);

export default StripePayment;

💡 It's important to round up the result of grandTotal * 100, because in some cases we may get floating point numbers from multiplication. Sounds interesting? Do a research to know why 18335.44 * 100 = 1833543.9999999998 🙂

import { useState } from 'react';
import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js';
import type { PaymentIntentOrSetupIntentResult, StripeError } from '@stripe/stripe-js';
import type { AxiosError } from 'axios';
import Message from '@adam/Message';
import Button from '@adam/Button';
import { useCheckoutContext } from '@adam/checkout/CheckoutContext';

export type NextActionHandler = (
  error: AxiosError<{ message: string }>
) => Promise<PaymentIntentOrSetupIntentResult>;

type StripePaymentHandlerProps = {
    billingName: string;
};

const StripePaymentHandler: React.FC<StripePaymentHandlerProps> = ({ billingName }) => {
    const stripe = useStripe();
    const elements = useElements();
    const [error, setError] = useState<string | null>(null);
    const { placeOrder } = useCheckoutContext();

    const handleAuthentication: NextActionHandler = (error) => {
        if (
          error.response?.status !== 400 ||
          !error.response.data.message.startsWith('Authentication Required: ')
        ) {
          // No auth required, continue normally
          return Promise.reject(error);
        }

        if (!stripe) {
            // Should never happen
            return Promise.reject(new Error('Stripe is not initialized'));
        }

        return stripe
            .handleNextAction({
                clientSecret: error.response.data.message.split(': ')[1],
            })
            .then((result) => {
                if (result.error) {
                    // Promise resolves with an error
                    return Promise.reject(result.error);
                }
                return result;
            });
    };

    const createPaymentMethod = () => {
        if (!stripe) return;
        stripe.createPaymentMethod({
            elements,
            params: {
                billing_details: {
                    name: billingName,
                },
            },
        }).then((result) => {
            if (result && result.paymentMethod) {
                setError(null);
                const poData = {
                    paymentMethod: { additional_data: { payment_method: result.paymentMethod.id } }
                };

                placeOrder(poData)
                    .then((res) => {
                        // Order successful
                    })
                    .catch((error) => {
                        // Additional auth required - e.g. 3D Secure
                        handleAuthentication(error)
                            .then(() =>
                                // Auth successful, try to place an order again
                                placeOrder(poData)
                                    .then((res) => {
                                        // Order successful
                                    })
                                    .catch((error) =>
                                        setError(error.message || "Unknown error")
                                    )
                            )
                            .catch((error) => setError(error.message || 'Unknown error'));
                    });
            }
        }).catch((error: StripeError) =>
            setError(error.message || 'Unknown payment error')
        );
    };

    const submitElements = () => {
        if (elements) elements.submit().then(() => createPaymentMethod);
    };

    return (
        <>
            <PaymentElement />
            {error && <Message>error</Message>}
            <Button onClick={submitElements}>Place Order</Button>
        </>
    );
};

export default StripePaymentHandler;

Place order

As you already noticed in the above component I use placeOrder function from useCheckoutContext hook. This function should be a chain of two promises - setting payment information and placing the order.

POST /rest/${storeCode}/V1/guest-carts/${cartId}/set-payment-information
{
    paymentMethod: {
        method,
        additional_data,
    },
    billingAddress,
    email,
}

If setting the payment information is successful make an attempt to place the order. Under the hood Magento will talk with Stripe service (server to server) and place the order if the payment gets approved.

PUT /rest/${storeCode}/V1/guest-carts/${cartId}/order

Payment promise scenarios

What will happen next in the order placement process can be described as four scenarios which we want to handle in our application's checkout logic:

  • Payment gets accepted and the order gets placed. Then we want to display the order confirmation page.
  • Payment gets declined. Stripe reported an error after receiving initial payment information. Then we want to display a message.
  • Order gets declined. Stripe accepted initial payment information, but Magento reported an error. Then we want to display a message.
  • Payment requires additional authentication. Then we want to display a modal to perform the authentication.

Few words about scenarios

Payment gets accepted and the order gets placed

Magento talks with Stripe and there's everything fine about the payment. Magento stores the order and returns the order ID, so we can notify the user about successfully completing the process.

Payment gets declined

Magento talks with Stripe and there's something wrong about the payment. Magento returns an error message which we can show to the user.

Order gets declined

Something is wrong about the order itself. Payment process gets interrupted.

Payment requires additional authentication

In my opinion it's the most interesting scenario. To handle it is a responsibility of the handleAuthentication function. It could be the case when e.g. there's a 3-D Secure Authentication required. When Magento returns a 400 status code response with the message Authentication Required: client-secret-goes-here we know we need some additional action. Let's pass the client secret to Stripe's handleNextAction function and it will know what to do next, e.g. it will display an authentication popup.

Summary

Stripe is a good quality choice and when integrated with SPA, it gives us a smooth and modern shopping experience. It's also relatively simple to set it up.


Adam Kaczmar

About the author

Adam Kaczmar, Web Developer

I've been a professional full-stack web developer since 2015. My experience comes mainly from e-commerce and consists of:

  • developing highly customized e-commerce software,
  • automating catalog and order integrations with external warehouse services,
  • creating tailor-made user-friendly administration tools for client teams,
  • creating front-end React / Next.js applications along with headless Magento, Laravel and Sanity back-ends.

Besides my programming job, I'm a husband, a father of two lovely daughters and I train boxing every Monday afternoon. Movie genere of my choice is western.

Want to talk? 🙂 Reach me on LinkedIn

...or explore all blog posts ➡️