This is the end-to-end build: a working GoHighLevel Marketplace app with a NestJS backend and a React front end. By the end you'll have the four pieces every real GHL app needs, wired together: the OAuth install flow, per-location token storage with refresh, a Custom Page that renders inside the CRM, and a webhook handler that reacts to events. We'll keep the code focused and point to the deep-dive articles for each piece, so this reads as the assembly guide rather than a repeat of the details.
The stack is deliberate. NestJS gives you clean modules, dependency injection, and guards, which map neatly onto "auth module, webhooks module, API module." React handles the Custom Page. If you use a different stack, the shape is the same; only the syntax changes.
The architecture in one picture
Four modules, one job each:
- Auth: runs the OAuth flow and stores tokens per location.
- GHL client: a thin service that adds auth + the
Versionheader and callsservices.leadconnectorhq.com. - Webhooks: verifies signatures and enqueues events.
- Custom Page: a React app that GHL loads in an iframe, backed by a session endpoint.
Step 1: the OAuth install (NestJS)
Two routes: one that redirects to GHL's authorization screen, one that receives the code and exchanges it for tokens. This is the spine of the app. The full explanation, including the rotating refresh token, is in How to Build a GoHighLevel Marketplace App with OAuth 2.0.
@Controller('oauth')
export class OAuthController {
constructor(private auth: AuthService) {}
@Get('install')
install(@Res() res: Response) {
const params = new URLSearchParams({
response_type: 'code',
client_id: process.env.GHL_CLIENT_ID,
redirect_uri: process.env.GHL_REDIRECT_URI,
scope: 'contacts.readonly contacts.write',
});
res.redirect(`https://marketplace.gohighlevel.com/oauth/chooselocation?${params}`);
}
@Get('callback')
async callback(@Query('code') code: string, @Res() res: Response) {
await this.auth.exchangeCode(code); // POSTs to /oauth/token, stores per locationId
res.send('Installed. You can close this window.');
}
}
Step 2: store tokens and refresh them (NestJS)
Each install has its own token pair, keyed by locationId. The AuthService persists them and hands out a valid access token on demand, refreshing when needed. The one rule you cannot skip: GHL rotates refresh tokens, so every refresh must save the new refresh token too.
@Injectable()
export class AuthService {
constructor(private prisma: PrismaService) {}
async getValidToken(locationId: string): Promise {
const install = await this.prisma.install.findUnique({ where: { locationId } });
if (install.expiresAt.getTime() - Date.now() > 60_000) return install.accessToken;
return this.refresh(install); // refresh + persist the NEW refresh_token
}
// exchangeCode() and refresh() both POST to https://services.leadconnectorhq.com/oauth/token
}
Step 3: a GHL client service (NestJS)
Wrap every API call in one service so the auth header, the Version header, and rate-limit handling live in a single place. This is where you add the burst-limit pacing from GoHighLevel API Rate Limits Explained.
@Injectable()
export class GhlService {
constructor(private auth: AuthService) {}
async get(locationId: string, path: string) {
const token = await this.auth.getValidToken(locationId);
const res = await fetch(`https://services.leadconnectorhq.com${path}`, {
headers: {
Authorization: `Bearer ${token}`,
Version: '2021-07-28',
Accept: 'application/json',
},
});
if (res.status === 429) { /* back off + retry, see the rate-limits guide */ }
return res.json();
}
}
Step 4: the webhook handler (NestJS)
Verify the Ed25519 signature against the raw body, acknowledge fast, and enqueue. Idempotency lives in the background worker. The details, including the July 2026 signature change, are in How to Handle GoHighLevel Webhooks.
@Post('webhooks/ghl')
handle(@Req() req: RawBodyRequest<Request>, @Res() res: Response) {
const sig = req.headers['x-ghl-signature'] as string;
if (!this.webhooks.verify(req.rawBody, sig)) return res.status(401).send('bad signature');
this.webhooks.enqueue(JSON.parse(req.rawBody.toString()));
return res.status(200).send('ok'); // process asynchronously
}
Enable rawBody in NestJS (NestFactory.create(AppModule, { rawBody: true })) so you can verify the exact bytes GHL signed.
Step 5: the Custom Page (React)
GHL loads your React app in an iframe and sends the user context by postMessage. The browser asks for it, ships the encrypted blob to your NestJS session endpoint, and your server decrypts it with the shared secret. Never decrypt in the browser. Full walkthrough in GoHighLevel Custom Pages.
function useGhlSession() {
const [session, setSession] = useState(null);
useEffect(() => {
window.parent.postMessage({ message: 'REQUEST_USER_DATA' }, '*');
const onMsg = async ({ data }) => {
if (data?.message !== 'REQUEST_USER_DATA_RESPONSE') return;
const r = await fetch('/api/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ encrypted: data.payload }),
});
setSession(await r.json()); // { userId, companyId, role, ... } decrypted server-side
};
window.addEventListener('message', onMsg);
return () => window.removeEventListener('message', onMsg);
}, []);
return session;
}
One hosting note that saves an hour of confusion: don't send X-Frame-Options: DENY or SAMEORIGIN on the Custom Page, or GHL can't embed it and you get a blank screen.
How it all connects
A user installs the app (OAuth) and their tokens land in your database, keyed by location. They open your Custom Page inside GHL; the page decrypts their context server-side, finds their install, and shows their data through the GHL client service. Meanwhile, webhooks stream changes in so your app stays current without polling. Four modules, each doing one thing, and the whole app is easier to reason about because of it.
Ship it
Start with just the OAuth flow and a single API call. Get that working end to end before you add the Custom Page or webhooks, because everything else depends on having valid tokens. Then layer in the webhook handler, then the Custom Page. Building in that order means each piece works before the next one leans on it. For the concepts behind each module, the GoHighLevel custom development guide is the map, and if you're weighing whether you even need OAuth, read Private Integration Tokens vs Marketplace Apps first.
Frequently asked questions
Do I have to use NestJS and React?
No. The four-module shape (auth, client, webhooks, custom page) works in any stack. NestJS and React just make the structure clean and the Custom Page easy.
What should I build first?
The OAuth install and one authenticated API call. Tokens are the foundation; the Custom Page and webhooks both depend on having them.
Where does the user context come from?
GHL sends it to your Custom Page's iframe by postMessage as an encrypted payload. You decrypt it on your NestJS backend with the shared secret and get the user id, company id, and role.
How do I avoid rate-limit problems in a bulk sync?
Put all API calls behind one client service, read the rate-limit headers, cap concurrency, and retry 429s with backoff. Better yet, use webhooks so you rarely need bulk polling at all.




