Avalara’s Stripe Integration

Chander Ramesh
9 min readJun 11, 2024

--

If you ever have the misfortune of integrating with AvaTax for sales tax compliance, then this post is for you. (And may God help you…)

For most small startups, I absolutely would not recommend Avalara. If your company is US-only, or even just North America-only, then there are hundreds of solutions that are superior in every way to Avalara.

But if you operate globally, then you have only three realistic options: Avalara, Thompson Reuters, and Vertex. Truthfully, if you’re an engineer having to implement any of these, you likely don’t have much say. Instead, your head of finance will evaluate cost, ease of filing in hundreds of jurisdictions globally, and ease of remittances.

All of these factors certainly outweigh a one-time sales tax implementation cost by engineering, as they should. And while I have not personally integrated Thompson Reuters or Vertex, I will mention that Avalara’s integration was extremely painful. The market is an triopoly, and there’s not much pressure for improvement.

Showing Tax-Inclusive Prices

Before we head into the weeds of Avalara and their numerous flaws, I wanted to mention that it’s highly likely that the other two also have their flaws, and that I’m just unaware of all the details.

Grammarly for example uses Thompson Reuters’ ONESOURCE. Reuters has a rate limit of 100 tax determination calls per minute, which is quite difficult to increase. At Grammarly’s volume they could increase that to 500 minute after an arduous negotiation.

Naively, you might just decide to add a cache layer and problem solved, right? In fact, Grammarly used to pre-ship the entire map of US zip codes and their corresponding tax rates in a simple hashmap directly to the client.

The problem is that, particularly for business plans, certain types of expenses are VAT-exempt — they may want to VAT registration numbers or certificates to dispute it. Detecting when to invalidate the cache is not so trivial, so then the backend must handle this.

This is what Grammarly does today. Everytime the country or postal code is modified, three calls are made to the backend, which presumably will intelligently fallback to ONESOURCE if the cache has been invalidated.

A /validate call made to first verify this is a real postal code
Then a call made to determine what is the tax paln for a particular postal code

The advantage to this approach is that users know ahead of time exactly what they’re paying, tax inclusive. As you’ll see down below, this is not possible with Avalara’s Stripe integration.

At Motion we spent numerous calls with various levels of Avalara’s customer support (both technical and not), and ultimately it’s not possible. Avalara’s Stripe integration relies on webhooks, which only fire after the transaction has already occurred.

There is no API that is provided by Avalara to simply calculate the tax but not add it to our liability. So if a user tried three different postal codes before finalizing on a fourth, we’d be liable for those three different codes that didn’t even have a subscription purchase!

Transaction Count Shenanigans

Another cause for concern is that Avalara charges by transaction count — which is basically a proxy for how many people have actually hit their API. I would highly recommend having a periodic audit of this number. I’ve spoken to no fewer than three Avalara customers who complained that there were ghost transactions happening where Stripe showed 20 purchases, but Avalara was charging them for ~800 purchases.

If you can, I’d recommend setting an extremely high transaction count in any contract you sign, or at the very least putting Avalara on an easily cancelable credit card so you can dispute charges or simply file the card as lost.

They may send you to collections (this happened to Alex Bass!), but at least you won’t be paying thousands of dollars for something you never even purchased.

Sandbox Woes

Avalara’s sandbox goes down. A lot.

I spent probably 15 hours total debugging various issues, wracking my brains, wondering why transactions simply would not register on the Avalara sandbox, only to reach out to support and find out four days later that — oops — sandbox had been down during the period I was testing, but it was back up.

No notice or banner on the website. Fortunately, production is much more reliable, but just know that sandbox testing is brutal.

Also, it’s not possible to log into both Avalara and Sandbox in the same window, so you mustuse incognito mode. I’d recommend pairing with your Head of Finance because more laptops means more chances of getting sandbox to work.

There were days when the sandbox simply would not load for me no matter what I did, but my coworker who was on the same Wifi, same laptop model, same everything, was able to get it to work just fine. 🤷‍♂️

Integration Guide

AvaTax has a built in “ZeRo CoDe SoLuTiOn”, which (spoiler) requires a ton of work to get right.

  1. Ask Stripe to use pay_immediately

This is not documented anywhere in Stripe, and it’s a private API used just for Avalara. If you’re using Typescript, remember to add a custom type to let your code compile with your new parameter, because the Stripe SDKs do not ship with this private API.

The following is our @types/stripe-custom-typings.d.ts file:

import 'stripe'

declare module 'stripe' {
export namespace Stripe {
export interface SubscriptionCreateParams {
pay_immediately?: boolean
}
}
}

Then, whenever you create new subscriptions, make sure you pass the pay_immediately flag as false, like so:

const subscription = await this.stripe.subscription.create({
customerId,
items: [
{ price }
],
pay_immediately: false
}, { idempotencyKey })

2. Add metadata to Stripe Customers

Every customer must have the country and the zip code added to metadata.

Example metadata taken from our Stripe dashboard

Unfortunately, if you only provide zip code and country (the only two mandatory fields), every single Stripe customer will also get this “Address_Validation_Status” error added to their metadata.

This error only means that Avalara could not validate to a street level address, not that Avalara could not properly calculate the tax rate for this customer.

So all in all, you can completely ignore this error.

Protip — if you’re setting this on the backend (and you should), all you need to get the zip code and country is the payment method object.

await this.setSalesTaxMetadata(
customerId,
product || null,
paymentMethod.billing_details.address?.country,
paymentMethod.billing_details.address?.postal_code
)

async setSalesTaxMetadata(
customerId: string,
product: Stripe.Product | null,
country: string,
zipCode: string | null
) {
const metadata: {
Address_Country: string
Address_PostalCode?: string
} = {
Address_Country: country,
}
if (zipCode) {
metadata['Address_PostalCode'] = zipCode
}
// stripeSdkService is a wrapper for Stripe operations
// this makes our code more testable - you can think of it as
// await this.stripe.customer.update(...)
await this.stripeSdkService.updateCustomer(customerId, {
metadata,
})
}

3. You cannot use payment intents!

Payment intents are finalized from Stripe’s point of view and are not editable, so Avalara will not be able to modify the amount to append sales tax.

If you want to use Avalara, you must use Invoices (or SetupIntents, which wrap invoices). This shouldn’t be an issue as long as you’re using the new PaymentElement.

If you’re using the legacy CardElement however, you’re in for a rough time.

4. Setup webhooks

Avalara’s documentation here is pretty decent. Create a new webhook that listens to these six events:

For edge cases, remember to also add invoice.marked_uncollectible in case for whatever reason your customer is unable to pay.

Obviously, this is our dev webhook that points Stripe test mode to Avalara’s sandbox — you’ll have to set up another one for prod in Stripe live mode.

Refunds and Disputes

You’ll notice the above seven events don’t include anything about refunds or disputes! That’s because Avalara’s “zero code” integration to Stripe actually doesn’t handle refunds or disputes. You’ll have to manually respond to these events and call the Avalara API instead.

The biggest problem with Avalara’s API documentation is the inability to search anything. Between the website crashing and just horrible SEO, it took me three back and forths before they sent me the details I needed to construct a simple Avalara API request.

First, the authentication documentation. Avalara uses Basic auth with two options. You can either use email:password, or account_id:license_key. In either case, make sure to Base64 encode it and prefix it with “Basic”.

Second, the API endpoint to avoid a transaction. This one is thankfully straighforward. Given a Stripe invoice in_1234, you can run something like this:

curl --request POST \
--url https://sandbox-rest.avatax.com/api/v2/companies/DEFAULT/transactions/in_1234/void \
--header 'Authorization: Basic <redacted>' \
--header 'Content-Type: application/json' \
--data '{ "code": "DocVoided" }'

For refunds, the charge.refunded event is all you need. Look out for it, and if you receive one simply void the transaction.

Note that this does not handle partial refunds properly. If you want to instead apply a partial refund, you’d have to edit the transaction and apply a discount code. This is fine for US customers, but it gets more complicated for VAT cases.

For disputes, remember to only void the transaction if you lose the dispute. In a naive case, if you only subscribe to charge.dispute.closed events, and then check for the dispute status to be lost, there’s a small window of time when the money for a transaction is not in your bank account, but you’re still liable for this.

This approach is probably fine if you have many small transactions, but if instead you have a few very large transactions, you’re probably better off voiding the transaction as soon as the dispute is created, and eventually if you win the dispute simply reinstate the transaction.

Prices Metadata

Lastly, you need to set tax codes on every single price you use, and tell Avalara that the price is not tax inclusive. For most people this should be pretty simple, as long as you’re only selling one type of good.

An example price with tax code metadata

We used Corrily to A/B test our Stripe pricing, which unfortunately meant we had thousands of prices. Our head of finance helpfully mapped out every single price to its tax code, and I just wrote a script that looped through all our Stripe prices and updated the metadata accordingly.

Temporary Credit Card Holds

If you’re using the ideal Avalara flow, then you’re probably doing something like creating a $1 hold on a user’s credit card to validate it (via a manual capture payment intent that you never capture), and then eventually create a subscription after their trial is over.

These $1 holds are often numerous — you may have many customers that never actually convert to a trial, and ultimately this is just another vehicle for Avalara to bill you.

It also makes your finance team’s job muchharder because they have to comb through thousands of transactions that were ultimately just temporary and never actually converted to a sales tax liability.

Instead, do yourself and your finance team a favor by setting the “Switched Calculate Tax on Charge/Payment” setting to OFF.

This made our Avalara dashboards so much cleaner and also saved us a bunch of money

My Recommendation

If you only have a software product, then I highly recommend using Anrok. They have a true out of the box zero code solution which worked like a charm for us. There are some cases we still had to handle, like marking invoices as uncollectible, but they’re not only extremely responsive on Slack but the documentation and support is truly a godsend. It has been night and day compared to Avalara, and most importantly they’re specifically designed to work with Stripe. Their support team is incredibly familiar with Stripe primitives and can help diagnose any issue you may run into!

--

--