How I integrated 3D secure for recurring payments with Stripe

Takuya Matsuyama - May 31 '21 - - Dev Community

Hi, it's Takuya from Japan.

I'm running a SaaS app called Inkdrop which is a subscription-based service.
I use Stripe to accept payments with credit cards around the world.
Recently, I've got an email from Stripe that users can't renew their subscriptions under RBI regulations in India if your website doesn't support 3D secure:

https://support.stripe.com/questions/important-updates-to-rbi-regulations-on-recurring-card-payments-in-india

That's going to affect my service since I have customers from India.
So, I decided to support 3D secure authentication on my website.

In terms of implementation, there are several ways to implement a card form for recurring payments.
If you are already using Stripe Checkout, it's easy. All you have to do is enable 3D Secure in your Billing settings. Then, Stripe basically does all nicely for you.
However, I was using Stripe Elements and Sources API to provide a credit card form. While it provides highly customizable form components, it requires an additional complicated implementation for 3D secure authentication. Besides, the Sources API is no longer recommended.
Looks like my code is old since I've implemented it several years ago.
I thought it's time to switch my payment logic from Stripe Elements to Stripe Checkout.

In this article, I'll share how I migrated from Stripe Elements to Stripe Checkout. It'd be also helpful for those who are planning to adopt Stripe Checkout for your website. Let's get started.

Understand a new way to set up future payments

I was so confused when reading Stripe's documentation because my knowledge was outdated.
There are several new APIs you have to understand:

I try to explain them as simple as possible:
You've been able to use card tokens retrieved via the Sources API for recurring payments.
But the Sources are now replaced with Payment methods and Setup intents.
You can think like the Sources API has been subdivided into the Payment Methods API and Setup Intents API.
Valid payment methods are attached to customers.
You can use a payment method to charge a customer for recurring payments.
The Setup Intents API allows you to set up a payment method for future payments.
Stripe Checkout creates a Checkout Session for the customer. A setup intent is issued and managed by the checkout session. It attaches a payment method to the customer once successfully finished the session.

Enable 3D Secure

As the latest Stripe API supports 3D Secure out of the box, you can enable it from Settings -> Subscriptions and emails -> Manage payments that require 3D Secure:

Settings

Then, check your Radar rules from Settings -> Radar rules:

Radar rules
With this configuration, 3D secure will be requested when it is required for card. I don't know which is the best practice, so I try this rule for now.

Now, you are ready to integrate it!

4 pathways users input their card information

In Stripe, each user has a Customer object, and a Subscription object is associated with each customer, which allows you to manage his/her subscription status.
Inkdrop doesn't require card information when signing up because it provides free trials. Customers have the following 3 account statuses:

  1. trial - In free-trial
  2. active - Has an active subscription
  3. deactivated - The subscription has been cancelled 15 days after a payment failure

That completely depends on your business design but I guess it'd be one of the common design patterns. Note that they are my application-specific statuses stored in my server.
With those statuses, the Inkdrop users may input their card information when:

  1. The user adds/changes/updates the card detail
  2. The user starts paying it before the trial expires
  3. The trial has been expired
  4. The account has been deactivated

I'll explain how to deal with those cases with Stripe Checkout.

1. User adds/changes/updates card detail

This is the simplest case.
Users can do it anytime from the website.
Here is the billing page of Inkdrop:

Billing page

You can update the billing details on this page. Nothing special.
And when a user clicked 'Change / update card' button, it shows:


In this page, the website initiates a new Checkout Session by calling stripe.checkout.sessions.create on the server-side like so:

const session = await stripe.checkout.sessions.create({
  payment_method_types: ['card'],
  mode: 'setup',
  customer: customerId,
  success_url: redirectSuccessUrl,
  cancel_url: config.app.baseUrl + cancel_url,
  billing_address_collection: needsBillingAddress ? 'required' : 'auto'
})
Enter fullscreen mode Exit fullscreen mode
  • payment_method_types - Inkdrop only accepts credit cards, so it should be always ['card'].
  • mode - It specifies mode as 'setup' so that you can use the payment method for future payments.
  • success_url & cancel_url - You can specify redirection URLs where Stripe will navigate the user after the session.
  • billing_address_collection - If you need to collect the customer's billing address, you can do that on the Checkout page by specifying it as 'required'

On the website, it retrieves the Session data from the server when opened the above page. When the user pressed 'Input Card' button, it redirects to the Checkout page like so:

stripe.redirectToCheckout({ sessionId: session.id })
Enter fullscreen mode Exit fullscreen mode

Then, the user should see a page something like:

Checkout page

Test 3D Secure

Use the test cards listed in this page to test 3D secure.
You should get a popup iframe during a Checkout session as following:

3D secure

Pretty neat.

Process new payment method

After the user input the card information, the Checkout redirects to success_url. While Stripe automatically attaches the new card to the Customer object, it doesn't anything else for you.

So, on the success_url, the Inkdrop server does the following processes:

  1. Check the card brand is supported
  2. Use the new card as the default payment method
  3. Retry payment if necessary

While Stripe accepts JCB cards through the Checkout but Inkdrop doesn't support them, it needs to verify the card brand manually like so:

export async function checkValidPaymentMethod(
  paymentMethod: Object
): Promise<?string> {
  const { card } = paymentMethod
  if (card && card.brand.toLowerCase() === 'jcb') {
    await stripe.paymentMethods.detach(paymentMethod.id)
    return 'jcb'
  }
  return null
}
Enter fullscreen mode Exit fullscreen mode

It's necessary to set the new card as the default payment method manually on your server since Stripe only adds it to the customer:

await stripe.customers.update(paymentMethod.customer, {
  invoice_settings: {
    default_payment_method: paymentMethod.id
  }
})
Enter fullscreen mode Exit fullscreen mode

It's optional if your website provides a UI to select a default card for users.

If the user has a past-due invoice, Inkdrop retries to charge it:

const customer = await stripe.customers.retrieve(customerId, {
  expand: ['subscriptions']
})
const subscription = customer.subscriptions.data[0]
if (subscription.latest_invoice) {
  const latestInvoice = await stripe.invoices.retrieve(
    subscription.latest_invoice
  )
  if (latestInvoice && latestInvoice.status === 'open') {
    await stripe.invoices.pay(latestInvoice.id)
  }
}
Enter fullscreen mode Exit fullscreen mode

2. User starts paying before the trial expires

Some users may want to finish their free trials and start subscribing to Inkdrop. Users under the free trial would see this:

Free trial

To provide a way to manually finish their free trials, you have to create another subscription instead of updating the existing subscription.
Actually you can do so during the redirection hook but you shouldn't because there is a UX issue where the price won't be displayed in the Checkout session if you don't specify any line_items just as you saw in pattern 1.
For example, you will see it tries to charge $0 (¥0) for the subscription when you use Apple Pay, which is kind of weird:

Apple Pay

I hope Stripe will support updating the existing subscriptions with Checkout, but it isn't supported at the moment.
So, you have to create another subscription without a free trial and remove the old subscription to accomplish that.

In this case, create a Checkout session like so:

const session = await stripe.checkout.sessions.create({
  payment_method_types: ['card'],
  mode: 'subscription',
  customer: customerId,
  success_url: redirectSuccessUrl,
  cancel_url: config.app.baseUrl + cancel_url,
  billing_address_collection: needsBillingAddress ? 'required' : 'auto',
  line_items: [
    {
      price: plan,
      quantity: 1,
      tax_rates: [
        customer.metadata.country === 'japan' ? taxRateJpn : taxRateZero
      ]
    }
  ]
})
Enter fullscreen mode Exit fullscreen mode
  • mode - It must be subscription
  • line_items - A product to newly subscribe

As Stripe doesn't support dynamic tax rates in Japan, I had to implement it myself (Please support it!). People from outside Japan are exempt from payment of a consumption tax if your business is based in Japan.

By doing so, users can see the price like this:

Stripe checkout with a subscription item
After a successful checkout, you can cancel the old subscription during the redirection hook:

export async function removeOldSubscriptions(
  customerId: string,
  newSubscription: string
) {
  const { data: subscriptions } = await stripe.subscriptions.list({
    customer: customerId
  })
  const activeStatus = new Set(['trialing', 'active', 'past_due'])
  for (const sub of subscriptions) {
    if (sub.id !== newSubscription) {
      await stripe.subscriptions.del(sub.id)
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

3. Trial has been expired

This is similar to the pattern 2. Again, Checkout doesn't allow to update the existing subscription directly, you have to re-create a subscription for better UX. For that reason, you can't charge immediately from the trial expiration date. The subscription starts just on a day when the user input the card information.

Notify users of the trial expiration with webhook

It'd be nice to kindly notify users that their trial has been expired.

Do not send payment failure notifications because they will be surprised and get angry! In the early days, I got some complaints, screaming like "It's a scam! 😡" because they haven't intended to buy or inputted card information (yet). You need to kindly notify their trial expired instead.
I couldn't find that Stripe supports it, so I implemented it myself.

To accomplish that: When the trial expired and the user hasn't inputted a card, the first payment fails and an event invoice.payment_failed fires.
You can know the event through webhook.
In your webhook, check if the user has any cards attached like so:

export async function checkCustomerHasPaymentMethod(
  customerId: string
): Promise<boolean> {
  const { data: paymentMethods } = await stripe.paymentMethods.list({
    customer: customerId,
    type: 'card'
  })
  return paymentMethods.length > 0
}
Enter fullscreen mode Exit fullscreen mode

If the user doesn't have a card, then check the number of charge attempts. If it was the first attempt, lock the account like so:

const { object: invoice } = event.data // invoice.payment_failed
const customer = await stripe.customers.retrieve(invoice.customer)
// first attempt
if (invoice.attempt_count === 1) {
  // do things you need
  notifyTrialExpired(customer)
}
Enter fullscreen mode Exit fullscreen mode

I also display the notification about the expiration on the website like this:

trial expired notification

4. Account has been deactivated

A customer's subscription is canceled when all charge retries for a payment failed as I configured Stripe like this from Settings -> Subscriptions and emails -> Manage failed payments for subscriptions:

subscription status

On the website, it displays the account has been deactivated:

deactivated

To reactivate the account, you can simply create a new subscription via the Checkout. Then, process the account to reactivate in your server.

Changing the plan (Monthly ⇄ Yearly)

Inkdrop provides monthly and annual plans.
Users can change it anytime.
To change the existing subscription:

const { subscription, customer } = await getSubscription(userId, {
  ignoreNoSubscriptions: false
})
const item = subscription.items.data[0]
const params: Object = {
  cancel_at_period_end: false,
  // avoid double-charge
  proration_behavior: 'create_prorations',
  items: [
    {
      id: item.id, // do not forget!
      price: plan
    }
  ]
}
// If the free trial remains, specify the same `trial_end` value
if (subscription.trial_end > +new Date() / 1000) {
  params.trial_end = subscription.trial_end
}
const newSubscription = await stripe.subscriptions.update(
  subscription.id,
  params
)
Enter fullscreen mode Exit fullscreen mode

When required 3D secure for renewing the subscription

Stripe supports an option "Send a Stripe-hosted link for cardholders to authenticate when required".
So, Stripe will automatically send a notification email to your users when required an additional action to complete the payment.
But, it'd be also nice to display the notification on the website like so:

3D secure auth required

You can determine if the payment needs 3D secure authentication like so:

subscription.status === 'past_due'
const { latest_invoice: latestInvoice } = subscription
const { payment_intent: paymentIntent } = latestInvoice

if (
  typeof paymentIntent === 'object' &&
  (paymentIntent.status === 'requires_source_action' ||
    paymentIntent.status === 'requires_action') &&
  paymentIntent.next_action &&
  paymentIntent.client_secret
) {
  console.log('Action required')
}
Enter fullscreen mode Exit fullscreen mode

Then, proceed to 3D secure authentication by calling confirmCardPayment:

const res = await stripe.confirmCardPayment(paymentIntent.client_secret)
Enter fullscreen mode Exit fullscreen mode

Upgrade the API version

When everything is ready to roll out, it's time to upgrade the API version.
If you are using the old API version, you have to upgrade it to the latest version from Developers -> API version. You should see the upgrade button if you are on the old one.
Be careful to do this because it immediately affects your production environment!

API Version
I hope Stripe will allow testing the new API before upgrading it because I had many unexpected errors when switching it, which I left a sour taste in my mouth:

API failures

It's never been such simple without Stripe

I've implemented credit card payments with PayPal in the past but it was so complicated and hard. The documentation was not clear to understand.
Stripe is so easy to integrate compared to that.
I still have some small issues as I mentioned in the article, but I'm basically happy with Stripe.
Besides, Stripe's website, dashboard, and mobile app are so beautiful and I've got a lot of inspiration from them.
You will learn their good UX practices while building your product with Stripe.

That's it! I hope it's helpful for building your SaaS business.

Follow me online

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .