Stripe Custom Form in Vanilla JS

Given that it’s coming up to Christmas, there obviously couldn’t be a more appropriate time to think about adding or customising an e-commerce experience with your own take on the Stripe form.

Before you go any further

This article will be pretty long, as I will narrate the general process of responsibly creating forms in general, so if you a skip-to-the-end kind of person, you can find the source code here and the live payment form here.

Please also note that I will be demonstrating the server-side controller for a custom Stripe form in a hypothetical Node/Express web application using vanilla JavaScript on the front-end. So, if you like jQuery, or if you are just looking for an API reference for a Ruby, PHP or Go app, you can find official documentation about both at the Stripe website.

Finally, if you’re a Perl developer, I may in the future do a write-up on implementing Stripe with Perl, but for the moment I’m waiting to see whether it will be Perl 5 or Perl 6 that I will be using for that.

So, why a custom form?

You might ask why anybody would want to do this, since the Stripe payment widget loaded via iFrame is a pretty damn well-engineered piece of kit, helps you circumvent an otherwise expensive PCI Compliance audit, and saves you the trouble of styling your payment form.

Here are the reasons I wanted to create a customised experience:

  • The default Stripe Checkout widget does not support variable payment amounts
  • It’s also widget is kind of heavy
  • It looks great, like it was plucked out of OS X, but the gradients and solid theme might clash with your website/app’s UI (as it does with mine)
  • You may just want an exercise with your afternoon tea

Preamble: why Stripe?

Stripe is an innovative piece of kit, primarily because of how easy it is to work with for developers like myself — and also for its simple pricing model. Granted, it’s not the cheapest, but what you lose in the slightly higher transaction fees is more than compensated by the removal of headaches.

Considering other payment gateways offering easy PCI Compliance, SagePay’s iFrame is a trusty mule, but it’s a bit ugly, and implementing a customised experience is not as easy. You also need a merchant account and you need to pay a flat fee of £25 per month, so Stripe’s model of paying more, but only when you use their service, is instantly more attractive to startup companies and fledgling freelancers.

Paypal I do not even consider these days unless it’s specifically requested by a client, and even then, I just try to persuade them of the benefits of Stripe or SagePay. Paypal is simple to setup, but presents one of two unpleasant issues.

  • Either you use a generic button, direct the customer to Paypal and implement an IPN listener or
  • Credit card info gets passed to your server at some point — bye bye PCI Compliance.

Perhaps the most important advantage Stripe offers is the pleasantly surprising policy on international transactions and American Express: no additional fees are levied, making it a very good idea for businesses targeting multiple countries.

A final benefit: Stripe is one of the few payment gateways who will migrate your subscribers (should you have subscribers) to an alternative provider on your behalf.


  • A text editor
  • A web browser
  • A server
  • A web application
  • An SSL certificate and an encrypted connection to your web application

Getting started

A mistake many developers make is that they think Stripe is an entirely client-side solution to the problem of payments, and are suddenly overwhelmed to discover that some server-side logic must be established to fulfil the payments that are only initiated by the client-side library. Fortunately, the Stripe team provide robust server-side libraries as well.

Creating a custom form really couldn’t be much simpler, but for those who prefer to have some actual instructions on implementation, the client-side code is only provided with a strong dependence on jQuery and for fixed-amount payments. jQuery is a truly great library and, for a long time, most developers would have been foolish not to leverage its cross-browser compatibility features.

Now we’re in 2016, the issue of cross-browser JavaScript portability is no longer critical (as long as you don’t care about IE8 — which you shouldn’t since even Microsoft has officially discontinued support for it) jQuery represents little more than needless overheard. Some things are still simpler to do, sure, but the cost to operations per second is no longer justified, especially not on mobile.

Given that we want to allow our customer to pay-what-they-want, and given that we also do not want to use a needless and arguably heavy library, we will need to come up with our own variation. Fortunately, the JavaScript code doesn’t really look much different to the jQuery code in the end until you reach the point where you want to interrupt the form submission and subsequently XHR the form to your server along with the StripeToken.

Let us begin by understanding what Stripe does.

How Stripe works

Stripe helps developers and businesses avoid headaches about PCI compliance by preventing sensitive data from ever touching the vendor’s server. The data is handled by Stripe’s iFrame which returns a one-way encrypted token by which transactions are identified and processed by server-side business logic. Beautiful and simple to work with.

Perhaps the real beauty, however, is in the flexibility of the Stripe payment gateway. You have two options. Firstly, there’s Stripe Checkout, which loads a widget in an iFrame much like SagePay’s form integration solution, though a good deal prettier. Secondly, there’s Stripe.js, which gives you the power to implement your own payment form while still levying the Checkout widget’s strengths — like the validator functions, which very reliably check the authenticity of credit card numbers on the client-side (enabling good UX) while at the same time not checking those numbers against anything on your server.

Stripe.js is designed to intercept your payment form, pass the credit card details to Stripe’s servers, process the payment and return a token which is used to authorise the payment and charge the card from your web application.

The Payment Form

Yes, unsurprisingly, the first thing we need is a payment form. It will initially look something like this:

<form method="POST" action="/pay-controller"> <fieldset> <div class="field"> <label> <input type="email" id="email"> </label> </div> </fieldset> <fieldset> <div class="field"> <label> <input type="text" id="card-number"> </label> </div> <div class="field"> <label> <input type="text" id="cvc-number"> </label> </div> <div class="field"> <label> <input type="text" id="exp-month"> </label> </div> <div class="field"> <label> <input type="text" id="exp-year"> </label> </div> </fieldset> <button type="submit" class="btnpay">Pay</button> </form>

You could have a more bare-bones form, but chances are you’ll end up using div elements to give you control over the input sizing and layout. You may also have noticed the fact that I am wrapping input fields in labels rather than the arguably preferable use of the for= attribute on labels. This is, however, a necessity, as we shall see shortly.

The form action is left blank in a lot of Stripe's examples, but you want to point this to the route of your controller which is going to charge the credit card.

So, when the customer fills out this form, we need to pass the credit card details to Stripe, receive the Stripe token and then resubmit the form including the token. This is pretty straightforward if you’ve ever used a POST request to pass url-encoded data to a server via XMLHttpRequest before. For those of you who have not, I will cover it extensively so fear not.

Before we go any further, however, we need to change this form a little. We obviously want to validate the form on the client-side for a good user experience, though Stripe itself will validate any payment sent to its API for processing, and we will obviously include server-side validation as well. We want the price to be flexible (but greater than 0) and let’s say that we want the customer’s email address as well so we can mail out basic payment confirmation message. Our form then should look more like this:

<form method="POST" action="/pay-controller"> <fieldset> <div class="field"> <label> <input type="email" id="email" name="email" required> </label> </div> <div class="field"> <label> <input type="number" id="price-gbp" pattern="[1-9]\d*" required> </label> <input type="hidden" id="price-pennies" name="amount"> </div> </fieldset> <fieldset> <div class="field"> <label> <input type="text" id="card-number" pattern="[0-9]{13,16}" maxlength="16" required> </label> </div> <div class="field"> <label> <input type="text" id="cvc-number" pattern="[0-9]{3,3}" maxlength="3" required> </label> </div> <div class="field"> <label> <input type="text" id="exp-month" pattern="0[1-9]|1[012]" maxlength="2" required> </label> </div> <div class="field"> <label> <input type="text" id="exp-year" pattern="[0-9]{4,4}" maxlength="4" required> </label> </div> </fieldset> <button type="submit" class="btnpay">Pay</button> </form>

HTML5 input elements have come a long way, but there is still some inconsistency in which attributes really work in terms of validation. The most effective ones I have tested are required (obvious), and pattern—which allows you to supply a regular expression to test the input values entered by the customer, without JavaScript and in real-time, without additional libraries and event listeners. That said, in production you will want something more robust which will block form submission in browsers which do not honor the required or pattern attributes.

Note how there is now pattern attribute specified for the input type="email"—this is because the email type automatically feeds this field with an implicit pattern which is more effective and reliable than any regular expression you could supply in a pattern attribute. The same applies to the number type input, which will not accept letters and will prompt mobile users with the number keypad rather than the keyboard.

Personally, I don’t consider this good user experience (text input is always nicer to use) but for the sake of making things easier a bit later, we'll roll with this for now. If you want price formatting, there are some good patterns available at

The other input fields have been fielded with patterns and max-lengths to prevent 60-character CVCs—which can be confusing for the user, since some gauge the validity of their own responses according to when the input fields restrict further input. You might wonder why I am not specifying a minlength attribute—this is because support for minlength is limited to Chrome and other Blink-based web browsers, whereas support for pattern is remarkably good. Patterns are elegant because they allow you to validate both format and length, but we still specify maxlength because:

  • support for maxlength is all but universal
  • pattern does not restrict how long an entry can be in an input field, where maxlength does in most browsers

The whole point of these validation attributes rather than just importing a massive library? Firstly, to optimise for the future. One day, support will be comprehensive and the need for downloading a couple of form validation libraries will be a distant memory. Secondly, Stripe.js will perform the critical validations when we interrupt the form’s submission.

Finally, note where name attributes have been included and where they have not. If you've worked with forms before, you will know that input elements specifying a name will supply their input values as url-encoded data in the HTTP POST ready to be retrieved by your server. We want to catch the price entered by the customer, as well as their email address, so we give these input fields appropriate names. Crucially, we do not give names to the input fields which will bear credit card data, as this would cause that data to be posted to your server and our mission to achieve easy PCI compliance would fail rather abruptly.

The Import

Now we have a form, we can start building the application code. First, before anything else and probably best in the <head>, import the Stripe.js client-side library and immediately after set your publishable key.

<script src=""></script> <script> Stripe.setPublishableKey('YOUR_PUBLISHABLE_KEY'); </script>

Not much to say about this, except that nothing will work without it.

The Intercept

So, now we have an input ready to supply us with a price, we need to write the JavaScript to interrupt this form. There are two commonly used methods to interrupt form submission using JavaScript: preventDefault and return false. You will find many StackOverflow answers claiming that return false will cancel both the default event and event propagation through parent element event listeners, but this applies to jQuery events only. In vanilla JavaScript, the story is different. preventDefault successfully prevents the default event (as its name would suggest) while return false prevents neither the event nor propagation. To see the difference, here are two examples to compare: Vanilla JavaScript vs jQuery. However, when it comes to interrupting form submissions, the two are still more or less interchangeable—but I strongly advise the use of preventDefault given that it is 2016 and all.

<script> function stripeResponseHandler(status,response){ "use strict"; var paymentForm=document.forms['payform']; if (response.error) { document.getElementById('feedback').textContent=response.error.message; document.getElementById('btnpay').removeAttribute('disabled'); } else { var dataAmount=document.getElementById('amount'); var dataEmail=document.getElementById('email'); var; var formString="amount=" + dataAmount.value + "&stripeToken=" + stripeToken + "&email=" + dataEmail.value; function submitForm(f){ var xhr=new XMLHttpRequest(); xhr.onload=function(){ window.location=xhr.responseText; } xhr.onerror=function(){ document.getElementById('feedback').textContent="Error"; console.log(xhr.responseText); },paymentForm.action,true); xhr.setRequestHeader('Content-type','application/x-www-form-urlencoded'); xhr.send(f); } return submitForm(formString); } } !function(){ "use strict"; var paymentForm=document.forms['payform']; paymentForm.addEventListener('submit',function(e){ e.preventDefault(); var thisForm=this; document.getElementById('btnpay').setAttribute('disabled',true); var baseAmount=document.getElementById('amountbase').value | 0; var amountField=document.getElementById('amount'); function pound(a){ return a * 100; }; amountField.value=pound(baseAmount); if (amountField.value>0) { Stripe.card.createToken(thisForm,stripeResponseHandler); } else { alert('Please enter a payment amount'); document.getElementById('btnpay').removeAttribute('disabled');} }); }(); </script>

That’s probably enough for Part 1. Stay tuned for part 2!

Originally published at