The pain of integrating with QuickBooks' oAuth 2.0 API
Tags: api, tutorial, serverless, oauth
Published: 2023-07-29 20:13:27

## The Project

I've been working with a buddy of mine on a system that will take transactions from a system called "Planning Center" and automatically add them to QuickBooks. Easy enough I thought, as both systems have API's avalible. Then I hit a wall, I could not get the authentication working with the QuickBooks API, there fore nothing else I wanted to set up would work. This led to me looking things up for several hours over the course of 3 days until I found what all my mistakes were. 

I hope this can save someone time in the future, as most of this feels like a lack of information given from QuickBooks' Docs.

## The Tech Stack

- Cloudflare Pages (Workers API)
- Typescript
- QuickBooks' REST API

## QuickBooks oAuth Overview

The way QuickBooks' API is designed is that you

1. Create a QuickBooks "App" which you can then add to a company
2. Create (either automatically or manually) a URL that is sent to an end user who wants to add that created "App" to their own company.
3. After they have done the oAuth on their end, QuickBooks sends a request back to your server with a token you can use for future requests without human interaction, and as long as that token is refreshed every 100 days by your system, you won't need human interaction.

That's how most oAuth systems work, so there's nothing insanely different here. For development you're basically mandated to create a "sandbox" company so you can use dummy data to test, and that sandbox company comes with a different host for the API interaction. So I set to work. Using the [QuickBooks API Playground](https://developer.intuit.com/app/developer/playground) as a reference.

## The Pain

### Cloudflare Workers is NOT a Node.js Runtime

Early on in the docs, QuickBooks recommends you ustilize the community node sdk's to interact with the API. Simple right? Wrong. For this project I'm leveraging a web hook, that will point to a Cloudflare serverless endpoint running in Cloudflare Workers. Cloudflare Workers however don't use a Node.js runtime, the closest they get is exposing node API packages as basically polyfills.

What I found however, is that this compatibility layer is hit and miss especially if you are attempting to leverage other dependencies from NPM as whatever packages the used package uses also need supported by the node compatibility layer. 

So after messing with that for a while I gave up and I decided to do the whole thing via REST so I didn't have to worry about external dependencies. That meant really making sure all my calls matched up with the docs since I wouldn't have a client handling that for me. Easy enough right? Well...

### The Difference of Sandbox and Production Hosts

I got to Step 2 in the Playground, "Get OAuth 2.0 token from auth code", which is the first step that interacts with the API.

_Incase you are curious step 1's generated url that you can construct manually would look something like: https://appcenter.intuit.com/connect/oauth2?client_id=<app client id here>&scope=com.intuit.quickbooks.accounting&redirect_uri=http://localhost:8788/api/quickbooks/setup&response_type=code&state=intuit-test This link can then be added to an <a href=""> tag for easy interaction, or emailed to a customer who needs to set this up etc. When they click it, it prompts them to login to quick books, choose a company and then sends back a code to the redirect uri specified which you'll use in step 2_

The playground gave me an example that looks like the following for Step 2:

```
POST /oauth2/v1/tokens/bearer?grant_type=authorization_code&code=AB11690658990VvPBYLG8Us6LufXe4fxKeNzc1zvmCF6mZRS7L&redirect_uri=https://developer.intuit.com/v2/OAuth2Playground/RedirectUrl

Content-Type: application/x-www-form-urlencoded
Accept: application/json
Authorization: Basic <base64 token omitted for security>
```

Since host name wasn't provided in the example I had assumed the host was like the host for content fetching.

For the sandbox:
https://sandbox-quickbooks.api.intuit.com

For production:
https://quickbooks.api.intuit.com

My original request looked like this (Note, Cloudflare supports "fetch" natively so that's what is used here, "fetcher" is a thin wrapper I made to handle query params more easily. I'll add that below if anyone wants to steal it):

```
fetcher(
    `https://sandbox-quickbooks.api.intuit.com/oauth2/v1/tokens/bearer`,
    {
      grant_type: "authorization_code",
      code,
      redirect_uri: "http://localhost:8788/api/quickbooks/setup",
    },
    {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        Accept: "application/json",
        Authorization: `Basic ${base64Credentials}`,
      },
    }
  );
```

On this request I got a `400 Bad Request` with no error message at all. I googled, I checked redirect urls, looked for all the possible reasons that I wouldn't even get an error message when I clearly was told to use

Then I found [this document](https://developer.intuit.com/app/developer/qbo/docs/develop/authentication-and-authorization/oauth-openid-discovery-doc), that talked about the multiple host names QuickBooks uses, and in retrospect it makes sense. The *data* from QuickBooks is different for sandbox and production companies, but the actual authentication itself is not sandboxed. It's all production authentication, so the host name should match.

I also found that the oAuth host is different than data requests entirely.

So I changed `https://sandbox-quickbooks.api.intuit.com` to `https://oauth.platform.intuit.com` and tada! It still didn't work, but I was getting `400 Bad Request` *with* an error message of `invalid_request` this time! Progress! I now was confident I was at least hitting a working api.

### Body Data, Query Parameters, and application/x-www-form-urlencoded

Now that I was getting `invalid_request` I was hoping to find some more information about it. However the only thing I could find was [this document that talked about why I might have an `invalid_grant` for that API.](https://help.developer.intuit.com/s/article/400-Invalid-Grant-invalid-grant) I triple checked everything here, that the redirect url was indeed added with the same exact path, trailing slashes, encoding etc. It all seemed right.

It was at that point, day 3, hours into this problem of just calling one single API, I was getting frustrated. I decided to dig through the [node SDK's code](https://github.com/intuit/oauth-jsclient) and look at how they made the request.

Firstly, shout out to them for having a [great sample](https://github.com/intuit/oauth-jsclient/tree/master/sample). I was able to prove that my set up is correct because the sample worked with the exact same redirect uri, code etc. 

## The working version

```
fetcher(
    `https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer`,
    null,
    {
      body: new URLSearchParams({
        grant_type: "authorization_code",
        code,
        redirect_uri: "http://localhost:8788/api/quickbooks/setup",
      }),
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        Accept: "application/json",
        Authorization: `Basic ${base64Credentials}`,
      },
    }
  );
```

## Notes

### The Fetcher Class
A typesafe, standard error handling, query param abstracted wrapper for Cloudflare's fetch.

```
export const fetcher = async <T>(
  url: string,
  params = {},
  options?: RequestInit<RequestInitCfProperties>
) => {
  const queryParams = new URLSearchParams(params || {});
  const constructedUrl = `${url}?${queryParams}`;
  const response = await fetch(constructedUrl, options);

  if (!response.ok) {
    const error = await response.text();
    throw new Error(
      `${response.status} ${
        response.statusText
      } requesting ${constructedUrl} with error: ${error || "unknown"}`
    );
  }

  const res = await response.json();

  return res as T;
};
```