How to Build a GoHighLevel Marketplace App with OAuth 2.0

A step-by-step OAuth 2.0 walkthrough for building a GoHighLevel marketplace app, with working Node.js code for install, callback, token storage, and refresh.

MMahzaib MirzaJuly 12, 20269 min read0 comments
How to Build a GoHighLevel Marketplace App with OAuth 2.0

To build a GoHighLevel marketplace app, you register the app in the developer marketplace, send agencies to HighLevel's OAuth screen to authorize it, exchange the returned code for an access token, and then call API v2 on their behalf. This guide walks the whole OAuth 2.0 flow with working Node.js code, including the token refresh that trips up most first apps. By the end you'll have a running authorization flow you can build a real product on.

If you only need to automate your own account, you don't need any of this. A Private Integration Token is faster. OAuth is specifically for apps that other HighLevel accounts install.

What you need before writing code

Create a developer account on the HighLevel marketplace and register a new app. HighLevel gives you three things that matter: a client_id, a client_secret, and a redirect URI you configure. The redirect URI is where HighLevel sends users back after they authorize, so it has to point at a route your server controls, for example https://yourapp.com/oauth/callback.

You also pick scopes. Scopes are the permissions your app is asking for, like contacts.readonly, contacts.write, or opportunities.write. Ask for the minimum you need. Agencies see these on the consent screen, and a shorter list gets more installs.

Step 1: send the user to the authorization screen

The flow starts by redirecting the agency to HighLevel's location chooser. This is where they pick which sub-account your app gets access to and approve the scopes.

// GET /install  (the button in your app that starts the flow)
app.get('/install', (req, res) => {
  const params = new URLSearchParams({
    response_type: 'code',
    client_id: process.env.GHL_CLIENT_ID,
    redirect_uri: 'https://yourapp.com/oauth/callback',
    scope: 'contacts.readonly contacts.write opportunities.write',
  });
  res.redirect(`https://marketplace.gohighlevel.com/oauth/chooselocation?${params}`);
});

Note the authorization host is marketplace.gohighlevel.com, not the API host. That catches people out, because every other call goes to services.leadconnectorhq.com. The authorization screen is the one exception.

Step 2: handle the callback and exchange the code

After the user approves, HighLevel redirects to your redirect_uri with a ?code= query parameter. That code is short-lived and single-use. You immediately trade it for tokens by posting to the token endpoint. The important detail: this request is form-encoded, not JSON.

// GET /oauth/callback
app.get('/oauth/callback', async (req, res) => {
  const { code } = req.query;

  const body = new URLSearchParams({
    client_id: process.env.GHL_CLIENT_ID,
    client_secret: process.env.GHL_CLIENT_SECRET,
    grant_type: 'authorization_code',
    code,
    user_type: 'Location',            // 'Location' for a sub-account, 'Company' for agency-level
    redirect_uri: 'https://yourapp.com/oauth/callback',
  });

  const r = await fetch('https://services.leadconnectorhq.com/oauth/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body,
  });

  if (!r.ok) return res.status(400).send(`Token exchange failed: ${await r.text()}`);

  const token = await r.json();
  // token = { access_token, refresh_token, expires_in, scope, userType, locationId, companyId, ... }

  await saveInstall(token);          // persist per locationId, see step 3
  res.send('App installed. You can close this window.');
});

The response gives you an access_token, a refresh_token, an expires_in (roughly 24 hours in seconds), and the locationId the tokens belong to. That locationId is your primary key for everything that follows.

Step 3: store tokens per location

One agency can install your app on many sub-accounts, and each install has its own token pair. So you store tokens keyed by locationId, never as a single global value. A minimal record looks like this:

async function saveInstall(token) {
  await db.installs.upsert({
    where: { locationId: token.locationId },
    update: {
      accessToken: token.access_token,
      refreshToken: token.refresh_token,
      expiresAt: Date.now() + token.expires_in * 1000,
    },
    create: {
      locationId: token.locationId,
      accessToken: token.access_token,
      refreshToken: token.refresh_token,
      expiresAt: Date.now() + token.expires_in * 1000,
    },
  });
}

Encrypt these at rest. They're live credentials to someone else's CRM, and treating them casually is how apps end up in incident reports.

Step 4: refresh before you call

Access tokens expire in about a day, so you refresh them. Here's the part that surprises people: HighLevel rotates refresh tokens. Every time you use a refresh token, you get a brand new refresh token back, and the old one stops working. If you don't save the new one, your next refresh fails and the user has to reinstall. So the refresh and the save have to happen together.

async function getValidToken(locationId) {
  const install = await db.installs.findUnique({ where: { locationId } });

  // Still valid? Use it. (60s buffer so we never call with an about-to-expire token.)
  if (install.expiresAt - Date.now() > 60_000) return install.accessToken;

  const body = new URLSearchParams({
    client_id: process.env.GHL_CLIENT_ID,
    client_secret: process.env.GHL_CLIENT_SECRET,
    grant_type: 'refresh_token',
    refresh_token: install.refreshToken,
    user_type: 'Location',
  });

  const r = await fetch('https://services.leadconnectorhq.com/oauth/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body,
  });
  const token = await r.json();

  await saveInstall(token);   // saves the NEW refresh_token too, this is the critical line
  return token.access_token;
}

Step 5: make an authenticated API call

Now the payoff. With a valid access token, calling API v2 is ordinary REST. Remember the Version header, which is required on every call except the token exchange you just did.

async function getContacts(locationId) {
  const accessToken = await getValidToken(locationId);

  const r = await fetch(
    `https://services.leadconnectorhq.com/contacts/?locationId=${locationId}`,
    {
      headers: {
        Authorization: `Bearer ${accessToken}`,
        Version: '2021-07-28',
        Accept: 'application/json',
      },
    },
  );
  return r.json();
}

That's a complete, working OAuth app: install, callback, storage, refresh, and a real call. Everything else you build is more endpoints and more UI on top of this spine.

The mistakes that cost the most time

  • Sending JSON to the token endpoint. It expects application/x-www-form-urlencoded. JSON gets you a confusing 400.
  • Dropping the rotated refresh token. Save the new refresh_token on every refresh, or installs silently break after a day.
  • Forgetting the Version header. Required on every v2 request. The error won't spell it out.
  • Using the wrong user_type. Location for a sub-account, Company for agency-level. Mismatches produce tokens that can't see what you expect.
  • Storing one global token. Store per locationId. Multi-install is the normal case, not the edge case.

Where to go next

Once installs work, add webhooks so your app reacts to events instead of polling, and mind the 100-requests-per-10-seconds burst limit when you loop over contacts. Both are covered in the GoHighLevel custom development guide. If you're still weighing whether you even need OAuth, the tokens versus marketplace apps comparison lays out the trade-off in one table.

Frequently asked questions

Where do I get the client_id and client_secret?

From the app you register in the HighLevel developer marketplace. Each app has its own credentials and its own configured redirect URI and scopes.

How long do access tokens last?

About 24 hours. Refresh tokens last much longer but rotate on every use, so you always save the newest one you receive.

What's the difference between user_type Location and Company?

Location authorizes a single sub-account. Company authorizes at the agency level. Pick based on what your app operates on, and stay consistent between the token exchange and refresh calls.

Do I have to publish the app publicly to test it?

No. You can install and test your own app in your own account during development. Public listing comes later and goes through HighLevel's review.

Share:
M

Written by

Mahzaib Mirza

Software developer & Founder of Coders Vibe.

Related Posts

Liked this post?

Get the next one in your inbox the moment it's published. No spam, unsubscribe anytime.

0 Comments

Leave a comment