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:
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:
Then, check your Radar rules from Settings -> 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:
-
trial
- In free-trial -
active
- Has an active subscription -
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:
- The user adds/changes/updates the card detail
- The user starts paying it before the trial expires
- The trial has been expired
- 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:
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'
})
-
payment_method_types
- Inkdrop only accepts credit cards, so it should be always['card']
. -
mode
- It specifiesmode
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 })
Then, the user should see a page something like:
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:
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:
- Check the card brand is supported
- Use the new card as the default payment method
- 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
}
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
}
})
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)
}
}
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:
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:
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
]
}
]
})
-
mode
- It must besubscription
-
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:
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)
}
}
}
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
}
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)
}
I also display the notification about the expiration on the website like this:
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:
On the website, it displays the account has been 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
)
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:
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')
}
Then, proceed to 3D secure authentication by calling confirmCardPayment
:
const res = await stripe.confirmCardPayment(paymentIntent.client_secret)
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!
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:
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
- Check out my app called Inkdrop - A Markdown note-taking app
- Subscribe Newsletter http://eepurl.com/dNgJo6
- Twitter https://twitter.com/inkdrop_app
- Blog https://blog.inkdrop.info/
- Discord community https://discord.gg/QfsG5Kj
- Instagram https://instagram.com/craftzdog