What We Learned Accepting ACH Payments with Stripe

When we started out, we expected integrating ACH (automated clearing house, or electronic funds transfer) payments into a web app to be relatively straightforward. Stripe provides great documentation to guide you through the process. It seems easy enough to request a user's bank info, wait for a couple of micro-deposits, confirm those deposits, and then issue the charge. However, there are many variables throughout the process and without good up-front planning, things could come back to bite you. Our experience led us to understand that the two most important aspects of a Stripe ACH integration are setting expectations with users and handling the variety of events that Stripe sends back to the app.

Setting User Expectations

A user doesn't necessarily understand that they can't just enter their bank info and the charge will go through. The process will take some effort on their part. It's very important to clearly lay out each step of the process and keep the user informed of where they are within it. After implementing ACH payments ourselves, we came up with a few rules to follow.

#1. Provide very clear step-by-step instructions up front When giving the user a choice of payment method (e.g. credit card vs. ACH), we also presented the user with a numbered list of the steps of the process with clear language detailing what the user will need to do. After reading about the three-step multi-day process, many users clicked the "Use a different payment method" button to make it easier on themselves.

#2. Follow up with the user to remind them about next steps Users will need to wait multiple days between steps and may need to come back to the app in the process. Without proper notifications in our initial release, we saw some users wait a couple weeks before confirming their bank info, and some didn't confirm their micro-deposits at all, leaving them with no option but to use a more immediate payment method like credit card. By sending an email or notifying the users another way, you guide the user and move the process along.

#3. Once a user finishes a step, set clear expectations about what they will need to do next This is where in-app messaging is very important. Once the user acts on something, like entering their bank account info or confirming their micro-deposit amounts, there should be a clear message about how long it will be until they need to act again and what they need to do at that point.

For example, after entering bank account information, we focused a user's attention with a modal window:

Handling Stripe Events

There are a number of success and failure events in the ACH process, and Stripe makes them easy to handle. These events allow your app to remain in sync with Stripe and support opportunistic notifications to users (i.e. only send an email to the user once Stripe tells you the payment succeeded or failed). You can support handling these events by utilizing Stripe's webhooks. There are particular events to which you'll want to pay attention:


Note: The events that matter to your app will vary depending on your implementation. However it's good to start with this list above and support more as you can.

A Code Example

It's always nice to see some implementation details when working with service integrations. So, we simplified some of our code to give an example of how we handled some of the integration (in Ruby on Rails, HTML, and Javascript).

Note: This is not a working code example. We left some code out in order to focus on the core functionalities.

Add a bank account through Stripe.js

It's easy to enter bank account info and receive a token representing the payment source. This method requires no processing of PII on your server.


<form id="ach-payment-form" accept-charset="UTF-8" method="post"> <label>Account Holder Type</label> <select autocomplete="off" name="account_holder_type"> <option value="individual">Individual</option> <option value="company">Company</option> </select> <label for="account_holder_name">Account Holder Name</label> <input id="account_holder_name" type="text" name="account_holder_name"> <label for="routing_number">Routing Number</label> <input id="routing_number" placeholder="110000000" type="text" name="routing_number"> <label for="account_number">Account Number</label> <input id="account_number" placeholder="000123456789" type="text" name="account_number"> <input name="agree_tos" type="hidden" value="0"> <input id="agree_tos" type="checkbox" value="1" name="agree_tos"> <label class="medium" for="agree_tos">I authorize to electronically debit my account and, if necessary,<br>electronically credit my account to correct erroneous debits.</label> <input type="hidden" name="country" value="us" /> <input type="hidden" name="currency" value="usd" /> <input type="submit" name="commit" value="Okay" class="float-right button primary" data-disable-with="Save "> </form> <script type="text/javascript" src="https://js.stripe.com/v3/"></script> <script type="text/javascript"> var stripe = Stripe('api-key'); $(function() { $('#ach-payment-form').submit(function(e) { e.preventDefault(); var $form = $(this); // Must agree to TOS before continuing var $checkbox = $form.find('[name="agree_tos"]:checked'); if ($checkbox.length < 1 || $checkbox.val() == false) { window.errorHandler.handle('You must check the box to agree to verify your bank account.'); return false; } var bankInfo = { routing_number: $form.find('[name="routing_number"]').val(), account_number: $form.find('[name="account_number"]').val(), account_holder_name: $form.find('[name="account_holder_name"]').val(), account_holder_type: $form.find('[name="account_holder_type"]').val(), country: $form.find('[name="country"]').val(), currency: $form.find('[name="currency"]').val() }; stripe.createToken('bank_account', bankInfo) .then(function(result) { if (result.token) { // result.token is an object that contains a token_id as well as a bank_account object // that will be used to lookup the bank_account later // // Save the data in result.token to your database here } else { // handle result.error here } }); }); }); </script>

Verify the bank account with micro-deposits

After 2-3 business days, Stripe will deposit two small amounts to the bank account (less than a dollar). These are then used to verify the bank account in order to process payments. We found that it is a good idea to follow up with the user 3 days after adding the bank account to remind them to check their bank account for the micro-deposits and provide them with a direct link to verify their bank account. Unfortunately, because of the way deposits work, Stripe is unable to send a webhook event for this part of the process.


<form id="verify-form" action="/payments/36/confirm" method="post"> <label>Micro Deposit #1 <input placeholder="$0.32" type="text" name="micro_deposit_1"> </label> <label>Micro Deposit #2 <input placeholder="$0.45" type="text" name="micro_deposit_2"> </label> <input type="submit" name="commit" value="Confirm" class="float-right button primary" data-disable-with="Save "> </form>


Stripe.api_key = "sk_test_mzAB9xmmuzttdwNVO8YIyXnZ" # For these methods you will need to find the stored token_id and bank_account_id from the previous step # token_id (e.g. tok_1bcf2tKmMGL5QhJZzYqadaf9) # bank_account_id (e.g. ba_17SHwa2eZvKYlo2CUx7nphbZ) # Create a Customer customer = Stripe::Customer.create( :source => token_id, :description => "Example customer" ) bank_account = customer.sources.retrieve(bank_account_id) # verify the account bank_account.verify(amounts: [32, 45]) charge = Stripe::Charge.create( :amount => total_amt, :currency => "usd", :customer => customer, :description => "Example payment" )

Handle payment events

Once the charge has been started, you have to wait for it to clear the ACH process. This can take up to 5 business days.

In order to be notified that the payment was successful, it's best to setup a Stripe webhook. You will need to handle at least charge.succeeded and charge.failed events in this case. We created a StripeEventHandlerService to respond to various Stripe events.


class StripeController < ApplicationController protect_from_forgery :except => :webhook respond_to :json def webhook begin event = Stripe::Webhook.construct_event( request.raw_post, request.headers['HTTP_STRIPE_SIGNATURE'], endpoint_secret ) StripeEventHandlerService.new(event).handle rescue Stripe::SignatureVerificationError => e render :json => { error: 'Invalid signature' }, :status => :bad_request rescue => e render :json => { error: e.message }, :status => :bad_request end end private def endpoint_secret ENV['STRIPE_WEBHOOK_ENDPOINT_SECRET'] || '' end end class StripeEventHandlerService # https://stripe.com/docs/api#event_types def initialize(event) @event = event @event.data = JSON.parse(@event.data.to_s).with_indifferent_access end def handle # We keep track of every transaction from Stripe Transaction.create_from_stripe_event(@event) handler_method = @event.type.tr('.', '_').to_sym if self.respond_to? handler_method, true self.send handler_method else unknown end end private def charge_failed payment = Payment.find(@event.data.try(:object)) payment.update_with_charge @event.data.object PaymentMailer.charge_failed(payment).deliver_now end def charge_succeeded payment = Payment.find(@event.data.try(:object)) payment.update_with_charge @event.data.object payment.send_after_payment_notifications end def unknown raise 'Unknown event type' end end

This code is a little high-level, but it illustrates the additional complexity involved in accepting ACH payments with Stripe. If you’re going to build an ACH payment process, remember you’ll need to account for multiple events that can occur before payments are approved as well as more touchpoints with the end user than credit card transactions. In need of help building an integration with Stripe? Feel free to reach out on Twitter.

The author's avatar

Matt helps to guide development and technology at One Design. His extensive experience in web operations and software development is put to use in the architecting and building of sites and apps, improving internal processes, and mentoring his teammates in their growth to become better developers.

Most Recent Issue