Custom Commerce Checkout Flow with Stripe

Commerce’s checkout experience can be tailored to meet the specific needs of your storefront. In this article, we’ll cover a basic custom checkout implementation using our first-party Stripe gateway and Stripe’s Payment Elements product.

Stripe payment elements

For new Commerce users, we recommend starting with our example templates. They are compatible with all gateways, and handle a number of edge cases and Commerce features that won’t be covered in this article.

Working on a headless site? A lot of this article still applies—as long as you have access to CSRF information, you can make the same Ajax requests from any kind of decoupled front-end.

A typical workflow using Stripe’s Payment Intents API looks something like this:

  1. Commerce creates a Payment Intent object via the Stripe API;
  2. The resulting “client secret” is used to initialize Stripe’s client-side Payment Elements;
  3. The customer selects and submits details for their desired payment method;
  4. A final request is made to Commerce to confirm the payment and complete the order;

Some payment methods require the customer to leave your site and complete an authorization process, or provide additional information via a third-party service.

This process is detailed in the official Stripe documentation—much of what follows is a Commerce-specific implementation of their payments quick-start guide.

Depending on your region, language settings, currency, domain, or a host of other factors, the specific payment methods available to you via Stripe’s payment elements interface will differ.

Check your Stripe dashboard for more information about what payment methods you can offer your customers.

Prerequisites #

This guide assumes you have already implemented some kind of cart management, and that your customer is ready to pay for their order. In addition, you will be expected to have…

We’ll pick up just as the customer is deciding how to pay for their order. The process will differ based on whether you want to support customer accounts and saved payment sources:

  • Guest Checkout: Payments always use a new method, entered during checkout.
  • Saved Payment Sources: A payment method was previously saved, and only a reference to that method is submitted at checkout.

We are going to approach the initial implementation as guest-only, then circle back to implement a second Stripe feature known as Setup Intents to save reusable payment sources.

Guest Checkout #

In this section, we will be creating an HTML form to hold a small amount of configuration for subsequent payment requests.

The following code assumes you have a Stripe gateway configured with a handle of stripe, and that your store does not give customers a choice between multiple gateways. It can be placed anywhere on your site, but we recommend a dedicated page or route.

{# Load the gateway’s definition: #}
{% set gateway = craft.commerce.gateways.getGatewayByHandle('stripe') %}

<form
  id="payment-form"
  data-stripe-publishable-key="{{ gateway.publishableKey }}"
  data-complete-payment-url="{{ actionUrl('commerce/payments/complete-payment') }}">
  {# Required info to route the request: #}
  {{ csrfInput() }}
  {{ actionInput('commerce/payments/pay') }}
  {{ redirectInput('shop/customer/order?number={number}') }}
  {{ hiddenInput('cancelUrl', 'shop/cart'|hash) }}

  {# Submit the gateway ID so Commerce knows how to set up the Payment Intent: #}
  {{ hiddenInput('gatewayId', gateway.id) }}

  <div id="payment-element-container">
    {# Stripe Elements will get initialized in here! #}
  </div>

  <button>Pay Now</button>
</form>

On its own, this form is insufficient to complete the order—all it’s doing is defining a few values (like the gatewayId) that tell Commerce how to handle the first step of our payment flow: creating a Payment Intent.

Note the two data-* properties on the form! We’ll be referencing these from JavaScript in a moment, using the dataset API.

Creating the Payment Intent #

Let’s connect our form to a bit of JavaScript that will handle that initial request. Below the form element, add two {% js %} tags:

<form>
  {# ... #}
</form>

{# Load the Stripe library: #}
{% js 'https://js.stripe.com/v3/' %}

{% js %}
  // Get a reference to the form element:
  const $form = document.getElementById('payment-form');

  // Initialize the Stripe SDK with the key we output on the form:
  const stripe = Stripe($form.dataset.stripePublishableKey);
{% endjs %}

The gatewayId input is not required if you have previously set a gateway on the cart.

The rest of the examples in this section will be part of the second {% js %} tag, so we won’t include any of the surrounding HTML or Twig!

At this stage, you could console.log($form); or insert a debugger; statement to make sure things are hooked up. Consider these templates and scripts as conceptual signposts, not a final implementation!

The next thing we need is a function that performs the initial request to Commerce that creates a Payment Intent. Add this to your script:

async function startPaymentFlow() {
  const response = await fetch('', {
    method: 'POST',
    headers: {
      'Accept': 'application/json',
    },
    body: new FormData($form),
  })

  const paymentResponse = await response.json();

  // ...
}

// Kick off the initial request as soon as the page is loaded:
startPaymentFlow();

Here, we’ve used the native browser fetch() API and the async/await pattern to make an action request to Commerce. All the inputs from our <form> are automatically added to the request’s body using a FormData object.

The paymentResponse object will look something like this:

{
    "cart": { /* ... */ },
    "redirect": "/shop/checkout/payment",
    "redirectData": {
        "client_secret": "pi_3NulqmIS4LqbsGJa0qORKorO_secret_IjFPfcrpEYCguVAlScUhFhfZr",
        "payment_intent": "pi_3NulqmIS4rqbsGJB0qORKorO"
    },
    "transactionId": "pi_3NulqmIS4Lqbh3JB0qORKorO",
    "transactionHash": "493f0c761264e17b104cb3dfb45327b2",
    "modelName": "paymentForm",
    "paymentForm": { /* ... */ }
}

The properties we care about most are redirectData.client_secret (which we’ll use to initialize the Stripe payment element), and the transactionId and transactionHash (essential for confirming the payment when an off-site redirection happens).

Initialize Payment Element #

This response means Commerce was able to talk to the Stripe API and create a Payment Intent object, and that we’re prepared to collect payment information from the customer, in the browser. Inside the startPaymentFlow() function (where the // ... appears), add this initialization logic:

// 1. Initialize Stripe Elements with the provided secret:
const elements = stripe.elements({
  clientSecret: paymentResponse.redirectData.client_secret
});

// 2. Create the payments UI:
const paymentElement = elements.create('payment');
paymentElement.mount('#payment-element-container');

// 3. Confirm the payment when the customer submits the form:
$form.addEventListener('submit', function(e) {
  e.preventDefault();

  handleSubmit(elements, paymentResponse);
});

There are three things happening, in this new block:

  1. We use the reference to the stripe object (created in the very first snippet) to initialize the Stripe Elements factory;
  2. A new Payment Element (the new combination UI that Stripe recommends using in place of the legacy “card” or “card number,” “expiry,” and “CVC” elements) is created and mounted to the #payment-element-container DOM element inside our form;
  3. We attach an event listener to the main <form> element that calls a handleSubmit() function instead of letting it submit normally—we’ll implement this in a moment;

At this point, you should be able to refresh your front-end payment screen and see the Stripe Payment Element rendered. In your browser’s Network tab, you can inspect the initial Ajax request to commerce/payments/pay, and compare the output to the example response, above.

Want to customize the Payment Element’s style? Stripe has an extensive appearance API, including easy-to-adopt themes.

Handle Payment #

Submitting the form by clicking the Pay Now button will result in an error because the handleSubmit() function is not implemented. Let’s look at what needs to go in there:

function handleSubmit(elements, payment) {
  // 1. Grab the base "return" URL off the form:
  const completePaymentUrl = new URL($form.dataset.completePaymentUrl);

  // 2. Add params for the transaction created during the initial Ajax request:
  completePaymentUrl.searchParams.append('commerceTransactionHash', payment.transactionHash);
  completePaymentUrl.searchParams.append('commerceTransactionId', payment.transactionId);

  // 3. Hand over control to Stripe:
  stripe.confirmPayment({
    elements,
    confirmParams: {
      return_url: completePaymentUrl.toString(),
    },
  })
    .then(function(res) {
      // Nothing to do after a successful submission!
    })
    .catch(function(err) {
      // Handle error messaging...
    });
}

This function’s responsibilities can also be split into three steps:

  1. Read the return URL off our HTML <form> element (we generated this in Twig, using the actionUrl() helper);
  2. Set additional parameters on the return URL that Craft needs to match complete the order after a payment is finalized;
  3. Call stripe.confirmPayment() to issue a payment against our Payment Intent;

The client_secret we initialized Stripe with is a temporary identifier that connects the customer’s Payment Method to the Payment Intent that Commerce created.

Consult the complete documentation for stripe.confirmPayment() for more information about its capabilities. Here, we’re only passing in the reference to our elements group, and a return_url that the customer should be forwarded to after the payment is complete.

If there are errors with the payment (or it requires additional authentication), Stripe will present a modal on-site, or—in exigent circumstances—redirect off-site to resolve issues.

Saved Payment Sources #

Let’s implement a “Save payment method for future purchases” option on our existing checkout flow. We’ll talk about how to use a saved payment source in the following section.

As a result of the Payment Intent being created as soon as the page loads (in order to initialize the Stripe Elements UI), the customer doesn’t have an opportunity to express their desire to save (or not save) their payment method—and the only way to change it after-the-fact is from the server.

In the legacy Stripe “Charge” workflow, the customer would tokenize a payment method client-side (at the time, this implied a credit card), then submit the token to Commerce along with additional options supported by the gateway—including a savePaymentSource param. This process is technically still supported, but dramatically reduces the number of payment methods available to your customers.

If you intend to support only credit cards, this may be a viable means by which to accept payments.

To remediate this, the Stripe gateway provides a dedicated controller action for updating a Payment Intent, after it is created. Let’s take advantage of this by adding a checkbox input to our payment form, and connecting it to some JavaScript that will talk to the Commerce back-end.

The first step is to update the form’s HTML. Add this just below the empty <div> that the Payment Element mounts to:

<input type="checkbox" name="savePaymentMethod" id="savePaymentMethod">
<label for="savePaymentMethod">Save payment method for future purchases</label>

Then, at the top of your script, capture a reference to the element:

const $form = document.getElementById('my-pay-form');
const $savePaymentMethodToggle = $form.elements.savePaymentMethod;

As long as you can uniquely identify the checkbox on your page, the rest of our JavaScript will be compatible—here, we’ve chosen to use the native HTMLFormElement.elements attribute to access an input by its name.

Now, collocated with the submit event listener we added to the form, listen for change events on the new checkbox input:

// (Form submit event listener code...)

$savePaymentMethodToggle.addEventListener('change', function(e) {
  handleSavePaymentToggle(elements, paymentResponse);
});

Finally, we’ll implement the handleSavePaymentToggle() function:

function handleSavePaymentToggle(paymentResponse) {
  // Freeze the input to prevent further changes:
  $savePaymentMethodToggle.disabled = true;

  // Build a new request payload:
  const paymentIntentParams = new FormData($form);

  // 1. Add the Payment Intent ID:
  paymentIntentParams.set('paymentIntentId', paymentResponse.redirectData.payment_intent);

  // 2. Overwrite the `action` param:
  paymentIntentParams.set('action', 'commerce-stripe/payments/save-payment-intent');

  // 3. Sync the POST value with our checkbox’s state:
  if ($savePaymentMethodToggle.checked) {
    paymentIntentParams.set('paymentIntent[setup_future_usage]', true);
  }

  // 4. Make the Ajax request:
  fetch('', {
    method: 'POST',
    headers: {
      'Accept': 'application/json',
    },
    body: paymentIntentParams,
  })
    .catch(function(err) {
      alert('Something went wrong when updating your payment preferences.');

      // 5. Revert the UI:
      $savePaymentMethodToggle.checked = !$savePaymentMethodToggle.checked;
    })
    .finally(function() {
      // In either case, unfreeze the input:
      $savePaymentMethodToggle.disabled = false;
    });
}

We’re making use of fetch() again to send a bit of data to Commerce—but this time, we have to make a couple of adjustments. Let’s take a closer look:

  1. Commerce requires a paymentIntentId param in the request so it can look it up via the Stripe API. We’re echoing back the ID that was returned in the first request;
  2. The action param that was suitable for the first request (quietly gathered from among the form’s hidden inputs, and originally generated in twig with actionInput()) has to be swapped out for the Stripe gateway’s controller action—remember, this is a feature specific to this gateway, and may not be necessary for others;
  3. We read the current value of the checkbox (after the change event has occurred), and conditionally set the paymentIntent[setup_future_usage] param;
  4. The request is sent with the fetch() API;

We don't need to act on a complete request—just failed ones. The .catch() handler gives us a chance to notify the customer that the change could not be made, then reset the input value. Similarly, the .finally() handler lets us clean up the input state so it’s not left disabled.

Keep in mind that some payment methods available via Stripe Elements aren’t eligible to be saved for reuse. Digital wallets are an example of this—they typically generate single-use payment details and are stored and protected on the customer’s device rather than with Stripe or a vendor.

You must have webhooks configured for saved payment sources to work!

Using a Saved Payment Source #

The process of paying with a saved payment source is significantly simpler—and doesn’t even involve JavaScript!

Logged-in customers may select a saved payment source at the time of payment, or configure it ahead of time and submit the order later—either way, you will need to provide them a way to submit a paymentSourceId:

{% set gateway = craft.commerce.gateways.getGatewayByHandle('stripe') %}

{% if currentUser %}
  {# Load the user’s saved payment sources: #}
  {% set sources = craft.commerce.paymentSources.getAllGatewayPaymentSourcesByCustomerId(gateway.id, currentUser.id) %}

  {# Are there any sources to offer? #}
  {% if sources|length %}
    <h2>Use a Saved Card</h2>

    <form method="post">
      {{ csrfInput() }}
      {{ actionInput('commerce/payments/pay') }}

      <ul>
        {% for source in sources %}
          <li>
            {{ input('radio', 'paymentSourceId', source.id) }}
            {{ source.description }} (<code>{{ source.token }}</code>)
          </li>
        {% endfor %}
      </ul>

      <button>Pay with this card</button>
    </form>
  {% endif %}
{% endif %}

<h2>Pay with a New Card</h2>

<form method="post">
  {# Same form from our previous examples! #}
</form>

You can submit a paymentSourceId in any request to commerce/cart/update-cart, and Commerce will keep track of it until checkout.

Bear in mind that setting a paymentSourceId on a cart means that any subsequent request to commerce/payments/pay will automatically use that saved payment source! If you wish to start the process over with a new payment method, make sure you send a blank paymentSourceId:

{{ hiddenInput('paymentSourceId', '') }}

This is important with the implementation above, because the page immediately preflights a request that begins the payment process.

Multiple Gateways #

The complexity of a custom checkout flow can grow quickly if you need to support multiple gateways. The easiest way to accomplish this is to separate the gateway selection and payment steps—for example, providing a checkout step called “How would you like to pay?” prior to the “Payment” screen.

Selecting a gateway is relatively simple, though—present the customer with a list of available gateways, and submit a gatewayId param to the commerce/cart/update-cart action:

<form method="post">
  {{ csrfInput() }}
  {{ actionInput('commerce/cart/update-cart') }}

  <label for="gateway-select">How would you like to pay?</label>

  <select name="gatewayId" id="gateway-select">
    {% for gateway in gateways %}
      <option value="{{ gateway.id }}">{{ gateway.name }}</option>
    {% endfor %}
  </select>

  <button>Continue</button>
</form>

Keep in mind that “gateway” is a technical term, and customers may not understand what they are being asked to choose. Naming your gateways something helpful like “Credit Card” (for Stripe) and “In-person cash or check” (for a Manual gateway), or using the Description field to explain how your store handles that kind of payment can greatly improve the experience.

You do not need to use a <select> element to submit the gatewayId—it could also be a series of radio inputs, or even a list of large button elements that automatically submit that value:

{# Within the gateways loop... #}
<button
  type="submit"
  name="gatewayId"
  value="{{ gateway.id }}">
  <strong>{{ gateway.name }}</strong>
  <p>{{ gateway.description }}</p>
</button>

Applies to Craft Commerce 4.