GoHighLevel Custom Pages: Building In-App UI for Your Marketplace App

How to build GoHighLevel Custom Pages: get your app to load in the CRM iframe and securely decrypt the user context server-side via the postMessage SSO flow.

MMahzaib MirzaJuly 12, 20266 min read0 comments
GoHighLevel Custom Pages: Building In-App UI for Your Marketplace App

GoHighLevel Custom Pages let a Marketplace app render its own screens inside the HighLevel interface: an onboarding flow, a settings panel, a dashboard, anything your app needs. To the user it feels native. Technically, your web app loads inside an iframe in the CRM, and GHL hands it the current user's context so you know who's looking. This guide covers the two things that make Custom Pages work: letting your app load in the iframe at all, and securely getting the user context so you can trust who the request is for.

Both trip people up in the same way, so let's be precise. The iframe won't load if your server sends the wrong headers, and the user context is only trustworthy if you validate it on your backend instead of believing whatever the browser tells you.

First, let your page load in the iframe

Custom Pages are rendered in an iframe inside HighLevel. That means your hosting has to allow cross-origin embedding. The classic mistake is shipping an app that sends X-Frame-Options: DENY or X-Frame-Options: SAMEORIGIN, which tells the browser to refuse embedding, so your page shows up blank inside GHL.

So do not send those headers on the Custom Page route. If you use a Content-Security-Policy, set frame-ancestors to permit HighLevel rather than blocking it. Everything else about hosting is normal: it's just a web page, served over HTTPS, that happens to render inside someone else's frame.

Get the user context, and verify it server-side

Your page loads, but who is it for? Which sub-account, which user, what role? GHL solves this with a postMessage-based flow. The parent CRM window sends your iframe an encrypted payload describing the current user. You decrypt it on your server using a shared secret, and you get structured data back: user id, company id, role, and location context.

The payload is encrypted with AES-256-CBC in OpenSSL's Salted__ format, and the shared secret is the key you generate in your app's Advanced Settings. The critical rule: decrypt and validate on your backend, never in the browser. The shared secret must never ship to the client, because anyone who has it can forge a user context.

// 1) In the iframe (browser): ask GHL for the encrypted user context.
window.parent.postMessage({ message: 'REQUEST_USER_DATA' }, '*');

window.addEventListener('message', ({ data }) => {
  if (data?.message === 'REQUEST_USER_DATA_RESPONSE') {
    // data.payload is the ENCRYPTED blob. Send it to YOUR server to decrypt.
    fetch('/api/session', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ encrypted: data.payload }),
    });
  }
});
// 2) On your server: decrypt with the shared secret (never expose it to the client).
import crypto from 'node:crypto';

function decryptUserContext(encryptedB64, sharedSecret) {
  const input = Buffer.from(encryptedB64, 'base64');
  // OpenSSL "Salted__" format: bytes 8-16 are the salt.
  const salt = input.subarray(8, 16);
  const data = input.subarray(16);
  const { key, iv } = deriveKeyAndIv(sharedSecret, salt); // EVP_BytesToKey, md5
  const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
  const out = Buffer.concat([decipher.update(data), decipher.final()]);
  return JSON.parse(out.toString('utf8')); // { userId, companyId, role, activeLocation, ... }
}

Once you've decrypted it server-side, you have a trusted identity for this session. Now you can look up that user's install, load the right data, and enforce permissions by their role. If you need an even stronger guarantee, GHL also offers a signed user-context flow you can validate on your backend, which is worth using when the page shows sensitive data.

Tie the context to a real API token

The user context tells you who is looking. To actually read or write their CRM data, you still need the access token for that location, the one you stored when they installed your app through OAuth. So the pattern is: decrypt the context to get the companyId or location id, look up the stored install for that id, and use its access token for API calls. The context authenticates the session; the OAuth token authorizes the API. If you haven't set up installs yet, start with How to Build a GoHighLevel Marketplace App with OAuth 2.0.

Build the page like any modern web app

Inside the iframe you're free to use whatever stack you like. A React or plain SPA works well, since Custom Pages are usually interactive: settings toggles, tables, forms. Keep the first paint fast, because it renders inside a CRM the user is already working in, and handle the "not yet authenticated" state gracefully while you wait for the postMessage handshake to complete. A blank flash before context arrives feels broken even when it isn't.

The short version

Custom Pages are what turn a background integration into a product people can see and click. Two rules keep them working: don't block the iframe with X-Frame-Options, and decrypt the user context on your server with a secret that never reaches the browser. Get those right and the rest is just building a web app. For where Custom Pages sit alongside webhooks and the API, see the GoHighLevel custom development guide.

Frequently asked questions

Why is my Custom Page blank inside HighLevel?

Almost always an iframe-blocking header. Remove X-Frame-Options: DENY or SAMEORIGIN from the page's response, and if you use CSP, allow HighLevel in frame-ancestors.

How do I know which user is viewing the page?

Request the user context with postMessage, then decrypt the encrypted payload on your server using the shared secret from your app's Advanced Settings. That gives you the user id, company id, role, and location.

Can I decrypt the user context in the browser?

No. That would require shipping the shared secret to the client, where anyone could read it and forge a context. Always decrypt and validate on your backend.

Does the user context let me call the API?

Not by itself. It identifies the session. To call the CRM API you use the OAuth access token you stored for that location when the app was installed.

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