Source: ai-research/ghl-2026-05-01/support-solutions-articles-155000003915-developer-guide-for-selling-web-widgets-on-the-app-marketplace.md

Custom Widgets are HTML/CSS/JS components that drop into the HighLevel funnel builder as drag-and-drop elements. The funnel builder hosts your settings UI inside an iframe; you communicate via postmate (a postMessage handshake library), emit the rendered HTML/CSS/JS for each widget configuration, and ship it as a .zip upload to the Marketplace. Reference repo: b805rohit/marketing-price-banner.

Key Takeaways

  • Build a small, self-contained web app with three functions: createHtml(), createCss(), and optionally createJS(). Together they emit the rendered widget code into the funnel.
  • Communication between your settings UI and the funnel builder uses postmate for iframe message passing. Emit a code event with { html, js, elementStore }; receive { elementStore } on initial handshake to prefill saved settings.
  • elementStore is your widget’s settings object — whatever shape you want, it gets persisted by HighLevel and re-passed to you on every reload so users see their saved configuration.
  • Upload format: .zip containing your build/dist output (relative paths only, never absolute). .rar and other archives are rejected.
  • Two custom events let your widget talk back to the funnel preview: customWidgetOpenPopup opens the funnel’s popup; customWidgetGoToNextStep navigates to the next funnel step.
  • Pre-submission checklist covers seven correctness/safety bars: doesn’t break the builder, doesn’t conflict with other elements, no unintended external scripts, preserves white-label, persistent state, correct initial render, all settings function.

Architecture

Funnel Builder (parent)
└── iframe → your settings web app (postmate)
                ↓ emit('code', { html, js, elementStore })
                ↑ receive ({ elementStore }) on init

Your app is hosted by HighLevel; you ship the static build. The funnel builder embeds it in an iframe when the agency user drops your widget into their funnel. Your app renders settings UI; whenever the user changes something, you emit the new HTML/CSS/JS and the new elementStore to the parent.

Required exports

FunctionReturnsRequired?
createHtml()HTML markup for the widgetyes
createCss()CSS for the widgetyes
createJS()Widget runtime JS (no <script> wrapper)optional

Settings example shape:

elementStore = {
  widgetHeight: 200,
  widgetWidth: 400,
  image: "https://..."
}

Initial handshake

On iframe load, expect this payload from the parent:

{ elementStore: Object } // previously-saved settings, or empty for first install

Use it to prefill your settings UI. Then immediately emit the initial render so the funnel preview shows the widget’s current state:

parent?.emit('code', {
  html: createHtml(),
  js: createJS(),
  elementStore: settings
})

Building the widget

Frameworks supported: vanilla HTML/CSS/JS, Angular, React, Vue, or any JS framework. If you’re using a router, configure it for memory mode (e.g., Vue Router’s createMemoryHistory) — hash and history modes break inside the iframe.

For mobile responsiveness inside the funnel builder’s mobile preview, target the .--mobile class prefix in your media queries — the builder applies that class to the wrapper when in mobile mode.

Upload requirements

  • Bundle into a .zip (not .rar or any other archive format).
  • Use relative paths only: ./css/style.css, never css/style.css or absolute paths.
  • For framework projects, zip the build/dist folder, not the source tree.
  • Folder layout:
widget/
├── index.html
├── css/
│   └── style.css
└── js/
    └── script.js

Custom widget events

Two browser events let your widget call back into the funnel preview:

customWidgetOpenPopup

Triggers the funnel’s popup (e.g., a CTA modal).

const event = new Event('customWidgetOpenPopup');
window.dispatchEvent(event);

customWidgetGoToNextStep

Navigates to the next step/page of the funnel.

const event = new Event('customWidgetGoToNextStep');
window.dispatchEvent(event);

These events fire inside the funnel preview environment, not your iframe. So include the dispatcher code in the JS you emit via createJS() (not in your settings app), since that’s the JS that actually runs inside the rendered funnel.

Funnel-builder integration

After your app is approved and live:

  1. The funnel builder lists your widget under “Custom Widgets” or a similar section.
  2. The agency user installs the widget from the Marketplace.
  3. They drag-and-drop it onto a funnel.
  4. Limited generic settings (margin, padding, visibility, custom classes) are editable inline in the funnel builder’s settings panel.
  5. Main widget configuration happens in your iframe-hosted settings popup.

Pre-submission checklist

Before you submit for review, verify:

  1. The widget integrates without breaking core funnel-builder functionality.
  2. It doesn’t visually or functionally conflict with other elements on the same page.
  3. No unintended external scripts are bundled. Self-contained code only.
  4. White-label preservation — no HighLevel/GoHighLevel branding leaks through.
  5. State persists across reloads and revisits.
  6. Initial render correctly reflects saved settings on first display.
  7. Every configurable setting actually works, end-to-end.

Try It

  • Clone https://github.com/b805rohit/marketing-price-banner as a working reference and study the postmate handshake.
  • Scaffold your settings app with Vite or CRA. Install postmate. Wire up parent?.emit('code', ...) on every settings change.
  • Define your elementStore shape early and avoid changing it once published — it’s the persistence contract with HighLevel.
  • Test inside an iframe locally by hosting the build and embedding it in a sandbox parent that mocks the postmate handshake.
  • Build, zip the dist folder with relative paths, upload via your developer portal app under Modules → Custom Widgets.
  • Walk the seven-point checklist before clicking Submit for Review — it’s the same checklist the review team will run against your app.

Open Questions

  • Whether HighLevel hosts the iframe payload on its own CDN or routes to a developer-supplied URL — the source says “Host (will take care of hosting)” suggesting HighLevel-hosted, but the upload-zip flow + dist folder suggests static hosting on their side.
  • Versioning model for widget updates — whether existing installations auto-upgrade when a new zip is approved or stay pinned until reinstalled.
  • Sandboxing/CSP rules for widget JS at runtime — what’s blocked vs allowed inside the rendered funnel.