Stripe Payment Integration — A Breakdown of the Implementation | Part 2

Sachin PatelSachin Patel
8 min read

Photo by Markus Spiske on Unsplash

In the previous post — Stripe Payment Integration — Key Concepts & Payment Flow | Part 1, we covered the key concepts and payment flow of Stripe’s PaymentElement. Now, we’ll walk through the implementation details of an already built integration, breaking down the core steps involved in handling payments.

Introduction

This guide will explain how the client and server interact, how PaymentIntents are created and confirmed, and how transactions are processed securely. By the end, you’ll have a clear understanding of how this integration works and how you can adapt it to your own application.

We will be referring these two apps to learn about the stripe integration.

Stripe Implementation Breakdown

In this section, we will look into each of the integration steps we had explored with the help of flow diagram in prvious post.

Step 1: Customer Initiates Payment:

Upon running the development server on the provided branch, you will see the following web app. Clicking the “Buy Now” button initiate the payment intent and redirects the user to a payment page where they can proceed with the payment.

Step 2: Client Sends Order Details to Server:

On the client-side, clicking the “Buy Now” button will post the Product ID to our server-side API endpoint /create-payment-intent.

Next: This end point generates a client_secret on server and returns the PaymentIntentId to client.

Here, PaymentIntentId is an ID generated on server to map an order (client_secret) which is initiated to make a payment.

const handleSubmit = async (event) => {
  event.preventDefault()

  try {
    // Create a payment intent on the server
    const data = await fetch(
      `${import.meta.env.VITE_API_ENDPOINT}/create-payment-intent`,
      {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ item: { id: product.id } }),
      }
    ).then((res) => res.json())

    if (data?.intentId) {
      navigate(`/order/payment/${data?.intentId}`)
      return
    }

    if (error) {
      console.error("Payment failed:", error)
      alert("Payment failed. Please try again.")
    } else if (paymentIntent.status === "succeeded") {
      console.log("Payment succeeded:", paymentIntent)
      alert("Payment successful! Thank you for your purchase.")
    }
  } catch (error) {
    console.error("Error during payment:", error)
    alert("Something went wrong. Please try again.")
  }
}

Step 3: Server Creates PaymentIntent:

On the server side, the /create-payment-intent endpoint follows three main steps, which are explained in detail after the code snippet below.


app.post("/create-payment-intent", async (req, res) => {
  const { item } = req.body

  // Step 3.1: Fetch product details from id
  const productData = await db.getProductDetails(item?.id)

  // Check if the product data is found
  if (!productData) {
    res.status(500).send({
      paymentIntent: "Data not found",
    })
    return
  }

  // Step 3.2: Create a PaymentIntent with the order amount and currency
  const paymentIntent = await stripeAPI.paymentIntents.create({
    amount: productData?.amount * 100,
    currency: productData?.currency?.toLowerCase(),
    // In the latest version of the API, specifying the `automatic_payment_methods` parameter is optional because Stripe enables its functionality by default.
    automatic_payment_methods: {
      enabled: true,
    },
  })

  // Step 3.3: Generate Intent Id and store it along with client_secret
  const intentId = await db.addBookingIntent(paymentIntent.client_secret)

  res.send({
    intentId,
  })
})

Step 3.1: First, The server fetches product details from the database based on the received Product ID.

It’s recommended to use database-stored price-sensitive information instead of relying on client-side inputs, as these can be susceptible to tampering.

Step 3.2: Create the paymentIntent using Stripe API `paymentIntents.create()` with the Order details fetched in previous step. On success, this API returns the `client_secret`.

Step 3.3: We will store this `client_secret` in database, mapped to a UUID. This UUID acts as an intent ID, allowing us to retrieve the `client_secret` in subsequent steps.

const addBookingIntent = async (clientSecret) => {
  // Alternatively you can call db.write() explicitely later
  // to write to db.json
  const intentId = uuidv4()

  db.data.bookingIntents.push({ id: intentId, clientSecret })
  await db.write()

  return intentId
}

Step 4: Client Displays Payment Form:

After receiving the Payment Intent ID from the previous step, the user is redirected to the payment page at /order/payment/${data?.intentId}.

On the payment page, the client makes a request to the server via the /get-payment-intent API endpoint to retrieve the client_secret using the Payment Intent ID.

Once the client_secret is received, the Elements provider from the react-stripe-js module is initialized. The PaymentElement component is then rendered, displaying the payment form for the user.

// src/pages/order/payment.jsx
// Render block of <PaymentPage ... />

const [clientSecret, setClientSecret] = useState()

useEffect(() => {
    if (clientSecret) return

    // Get the client_secret as soon as the page loads
    fetch(`${import.meta.env.VITE_API_ENDPOINT}/get-payment-intent`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ intentId: params.intentId }),
    })
    .then((res) => res.json())
    .then((data) => setClientSecret(data.clientSecret))
}, [])

...

{!clientSecret ? "Loading" : ""}
{clientSecret && (
    <Elements stripe={stripePromise} options={options}>
        <PaymentForm intentId={params.intentId} />
    </Elements>
)}
...

// src/pages/order/payment.jsx
// Render block of `<PaymentForm ... />` component

return (
<>
    <h1>Payment details</h1>
    <form onSubmit={handleSubmit}>
        <PaymentElement />

        <button
        disabled={!stripe}
        style={{
            marginTop: "18px",
        }}
        >
            Submit
        </button>
    </form>
    {error}
</>
)

Step 5: Customer Finalises Payment:

At this stage, the customer enters their card details or selects a wallet payment option. The client then confirms the PaymentIntent using stripe.confirmPayment(). To securely transmit the payment information collected through the Payment Element to the Stripe API, the Elements instance must be accessed, as it is required to work with the stripe.confirmPayment method.

// `<PaymentForm ... />` component - src/pages/order/payment.jsx
const handleSubmit = async (event) => {
  // We don't want to let default form submission happen here,
  // which would refresh the page.
  event.preventDefault()

  if (!stripe || !elements) {
    // Stripe.js hasn't yet loaded.
    // Make sure to disable form submission until Stripe.js has loaded.
    return
  }

  const result = await stripe.confirmPayment({
    //`Elements` instance that was used to create the Payment Element
    elements,
    confirmParams: {
      return_url: `${import.meta.env.VITE_BASE_URL}/order/status/${intentId}`,
    },
  })

  if (result.error) {
    // Show error to your customer (for example, payment details incomplete)
    setError(result.error.message)
    console.log(result.error.message)
  } else {
    setError("")
    // Your customer will be redirected to your `return_url`. For some payment
    // methods like iDEAL, your customer will be redirected to an intermediate
    // site first to authorize the payment, then redirected to the `return_url`.
  }
}

return (
  <>
    <h1>Payment details</h1>
    <form onSubmit={handleSubmit}>...</form>
    {error}
  </>
)

Step 6: Stripe Processes Payment:

Once the customer submits their payment details, Stripe takes over the process. Stripe first verifies the provided payment information and checks if authentication is required.

If Strong Customer Authentication (SCA) is needed (such as 3D Secure for European transactions), Stripe prompts the customer to complete the authentication process. After successful authentication (if required), Stripe attempts to charge the customer’s payment method.

Depending on the result, the payment can either be successful, require further action, or fail due to insufficient funds, incorrect details, or other reasons.

Step 7: Server Verifies Payment Status:

After Stripe processes the payment, it sends a webhook event (payment_intent.succeeded, payment_intent.payment_failed, or payment_intent.requires_action) to the backend server. The server listens for these webhook notifications to confirm the final payment status.

  • If the webhook event is **payment_intent.succeeded**, the server marks the order as paid and updates the database.

  • If the event is **payment_intent.requires_action**, the server notifies the frontend that additional authentication (e.g., 3D Secure) is needed.

  • If the event is **payment_intent.payment_failed**, the server logs the error and provides details on why the payment was unsuccessful.

Note: We haven’t implemented the webhook in our integration. Here, we have sample code from the stripe guide on setting up the webhook.

// This example uses Express to receive webhooks
const express = require('express');
const app = express();

// Match the raw body to content type application/json
// If you are using Express v4 - v4.16 you need to use body-parser, not express, to retrieve the request body
app.post('/webhook', express.json({type: 'application/json'}), (request, response) => {
    const event = request.body;

    // Handle the event
    switch (event.type) {
        case 'payment_intent.succeeded':
            const paymentIntent = event.data.object;
            // Then define and call a method to handle the successful payment intent.
            // handlePaymentIntentSucceeded(paymentIntent);
            break;
        case 'payment_method.attached':
            const paymentMethod = event.data.object;
            // Then define and call a method to handle the successful attachment of a PaymentMethod.
            // handlePaymentMethodAttached(paymentMethod);
            break;
        // ... handle other event types
        default:
            console.log(`Unhandled event type ${event.type}`);
    }

    // Return a response to acknowledge receipt of the event
    response.json({received: true});
});

app.listen(4242, () => console.log('Running on port 4242'));

Step 8: Client Updates Customer:

Once the server processes the webhook event, it sends the final payment status back to the client (frontend). The frontend updates the UI accordingly:

  • If the payment was successful, the customer sees a confirmation message and may be redirected to a success page.

  • If additional action is required (e.g., authentication), the frontend prompts the customer to complete it.

  • If the payment fails, the UI displays an error message with options to retry or use a different payment method.

Verify the transaction on Dashboard

Here’s the transaction list from the Stripe Dashboard, showcasing two different payment methods — one processed using the PayNow Wallet and the other using a Card.

Conclusion

In this post, we broke down the implementation of Stripe PaymentElement by walking through the key integration steps on both the client and server sides. We explored how PaymentIntents are created and managed, how to securely handle payment details, and how Stripe dynamically offers supported payment methods to users.

By understanding this integration, you now have a solid foundation for implementing a seamless and secure payment flow in your own application. Whether you’re handling card payments, wallets, or local payment methods, Stripe’s PaymentElement provides a flexible and user-friendly solution to optimize your checkout experience.

If you’re looking to extend this setup, consider exploring subscription billing, advanced fraud prevention, or post-payment actions to further enhance your Stripe integration. 🚀

0
Subscribe to my newsletter

Read articles from Sachin Patel directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Sachin Patel
Sachin Patel