GoHighLevel
GoHighLevel forms are React-controlled, which the tag handles. What matters is how the form is embedded. The inlineembed renders the fields into the page DOM, so the tag fills them directly — that's the simplest install. The iframeembed renders the form on GHL's own domain, where the tag can't reach it; there you pass the values in through the iframe's URL instead and let GHL prefill the fields itself. Both are covered below.
Add the tag to the funnel / site
In GHL, open Sites → Funnels (or Websites), pick the funnel, and go to Settings → Head Tracking Code. Paste the loader there (or add it via GTM if the container is on the page). Save.
<script src="https://attr.boggsmtk.com/v1/tag.js?k=YOUR_LICENSE_KEY"></script>
Swap YOUR_LICENSE_KEYfor the client's key. For a whole location, you can instead use Settings → Business Profile / Tracking Code.
If the form lives on a non-GHL site (WordPress, Webflow, a custom landing page) with the GHL form embedded into it, the tag goes on thatsite's pages — the tag always belongs on the page the visitor is actually looking at, never inside the form.
Pick your route — inline or iframe
Check the embed you're using. In Sites → Forms → Builder → Integrate Form, GHL offers both. A funnel built with the builder's native Form element is inline. An embed whose code is an <iframe src="…/widget/form/…"> plus form_embed.js is the iframe route.
- Inline embed — the tag fills the fields directly. Continue with step 3, then skip step 4.
- Iframe embed— you can't change it, or you need the popup / two-step form the iframe gives you. Skip to step 4.
Journey JSON and Attribution Summary.Inline embed — add a hidden field for each value
In the form builder, add a field for each value you want to capture and set it to Hidden. Two ways to populate it:
- By name — if the field's Query Key / name matches your field-map key (e.g.
mtk_first_source), the tag fills it by name. - By token — set the field's default / prefilled value to the
[mtk:…]token and the tag fills it by value.
Save the form and publish the funnel. You're done — skip to step 5.
Iframe embed — pass the values in through the widget URL
A cross-origin iframe can't be read from the parent page, so the tag can't reach these fields. What the parent page does control is the iframe's src, and GHL prefills any field whose Query Key matches a query parameter on that URL. The companion script below closes that gap.
4a · Add the companion script directly below the tag loader — in GTM (same Custom HTML tag, or its own tag on all pages), in Settings → Head Tracking Code, or in the <head>of whatever site hosts the embed. Nothing to configure: it forwards every value on the tag's dataLayer event, so a field added to the field map later arrives here on its own. Drop anything you don't want in the CRM into its EXCLUDE list — each one you drop frees room in the widget URL for the rest.
<!-- MTK Attribution GoHighLevel iframe Companion Script -->
<script>
(function () {
// Values to keep OUT. Both of these are long enough to crowd a URL past
// its ceiling on their own, and everything inside them already rides
// along in the individual fields. Add any field you don't want on the
// platform — each one you drop frees room for the rest.
var EXCLUDE = [
'mtk_journey_json',
'mtk_attribution_summary'
];
// Only when the platform's key has to differ from the field name.
var RENAME = {
// 'mtk_first_channel': 'first_channel'
};
// The tag also publishes each value under its retired spelling so older
// client forms keep filling. That is for forms, not for us — carrying both
// would double the payload for nothing.
var LEGACY_PREFIXES = ['mtk_ft_', 'mtk_lt_'];
var LEGACY_NAMES = [
'mtk_time_since_paid',
'mtk_conversion_timestamp',
'mtk_summary',
'mtk_touch_path',
'mtk_touch_path_json'
];
// Titles the generic rule can't produce (click IDs stay lowercase).
var TITLE_EXCEPTIONS = {"mtk_gclid":"gclid","mtk_gbraid":"gbraid","mtk_wbraid":"wbraid","mtk_msclkid":"msclkid","mtk_page_url":"Page URL","mtk_fbclid":"fbclid","mtk_fbc":"fbc","mtk_fbp":"fbp"};
function isLegacy(key) {
for (var i = 0; i < LEGACY_PREFIXES.length; i++) {
if (key.indexOf(LEGACY_PREFIXES[i]) === 0) return true;
}
return LEGACY_NAMES.indexOf(key) > -1;
}
// 'mtk_first_channel' -> 'First Channel', matching the Field Name the
// dashboard shows for that row.
function titleOf(key) {
if (TITLE_EXCEPTIONS[key]) return TITLE_EXCEPTIONS[key];
var words = key.replace(/^mtk_/, '').split('_');
var out = [];
for (var i = 0; i < words.length; i++) {
if (!words[i]) continue;
out.push(
words[i] === 'json'
? 'JSON'
: words[i].charAt(0).toUpperCase() + words[i].slice(1)
);
}
return out.join(' ');
}
// Order values are added in, which on a length-capped platform is also
// drop priority: what a lead is worth least without goes last. Anything
// not listed (a custom field, a new default) sorts after these, so a drop
// is predictable rather than whatever order the event happened to be in.
var PRIORITY = [
'mtk_last_channel', 'mtk_last_source', 'mtk_last_medium', 'mtk_last_campaign',
'mtk_gclid', 'mtk_gbraid', 'mtk_wbraid', 'mtk_msclkid',
'mtk_first_channel', 'mtk_first_source', 'mtk_first_medium', 'mtk_first_campaign',
'mtk_page_path', 'mtk_page_url',
'mtk_conversion_unix', 'mtk_conversion_datetime', 'mtk_custom_conversion_time',
'mtk_fbclid', 'mtk_fbc', 'mtk_fbp',
'mtk_last_landing_page', 'mtk_last_landing_page_group', 'mtk_first_landing_page',
'mtk_session_count', 'mtk_pageview_count', 'mtk_paid_touch_count',
'mtk_last_paid_touch', 'mtk_time_since_last_paid_touch', 'mtk_time_to_conversion',
'mtk_journey_string'
];
// Every mtk_* value the event carries, minus the excluded and the retired.
function keysFrom(data) {
var out = [];
for (var key in data) {
if (!data.hasOwnProperty(key)) continue;
if (key.indexOf('mtk_') !== 0) continue;
if (isLegacy(key)) continue;
if (EXCLUDE.indexOf(key) > -1) continue;
out.push(key);
}
out.sort(function (a, b) {
var ia = PRIORITY.indexOf(a), ib = PRIORITY.indexOf(b);
if (ia === -1 && ib === -1) return a < b ? -1 : a > b ? 1 : 0;
if (ia === -1) return 1;
if (ib === -1) return -1;
return ia - ib;
});
return out;
}
var WIDGET_PATHS = ['/widget/form/', '/widget/survey/'];
var URL_ATTRS = ['src', 'data-src', 'data-lazy-src', 'data-litespeed-src'];
var MAX_WAIT_MS = 3000;
var MAX_URL_LEN = 1800;
var pending = [];
var flushed = false;
function attributionData() {
var dl = window.dataLayer || [];
for (var i = dl.length - 1; i >= 0; i--) {
if (dl[i] && dl[i].event === 'mtk_attribution') return dl[i];
}
return null;
}
function isWidgetUrl(url) {
for (var i = 0; i < WIDGET_PATHS.length; i++) {
if (url.indexOf(WIDGET_PATHS[i]) > -1) return true;
}
return false;
}
function widgetUrl(el) {
if (!el || el.tagName !== 'IFRAME') return null;
for (var i = 0; i < URL_ATTRS.length; i++) {
var value = el.getAttribute(URL_ATTRS[i]);
if (value && isWidgetUrl(value)) return { attr: URL_ATTRS[i], url: value };
}
return null;
}
function withAttribution(url, data) {
if (!data) return url;
var keys = keysFrom(data);
var sep = url.indexOf('?') > -1 ? '&' : '?';
var qs = '';
var dropped = [];
for (var i = 0; i < keys.length; i++) {
var key = keys[i];
var value = data[key];
if (value === undefined || value === null || value === '') continue;
var pair = encodeURIComponent(RENAME[key] || key) + '=' + encodeURIComponent(String(value));
if (url.length + sep.length + qs.length + pair.length + 1 > MAX_URL_LEN) {
dropped.push(key);
continue;
}
qs += (qs ? '&' : '') + pair;
}
if (dropped.length && window.console && console.warn) {
console.warn(
'[MTK GHL] widget URL hit its ' + MAX_URL_LEN + '-character ceiling; ' +
'these did not fit: ' + dropped.join(', ')
);
}
return qs ? url + sep + qs : url;
}
function done(el, attr, url) {
el.setAttribute('data-mtk-done', '1');
el.setAttribute(attr, withAttribution(url, attributionData()));
}
function resolve(el) {
var parkedUrl = el.getAttribute('data-mtk-src');
if (parkedUrl) return done(el, 'src', parkedUrl);
var found = widgetUrl(el);
if (found) done(el, found.attr, found.url);
}
function consider(el) {
if (!el || el.nodeType !== 1) return;
if (el.getAttribute && el.getAttribute('data-mtk-done')) return;
var found = widgetUrl(el);
if (!found) return;
if (found.attr !== 'src') {
if (flushed) resolve(el);
else if (pending.indexOf(el) === -1) pending.push(el);
return;
}
if (flushed) return resolve(el);
el.setAttribute('data-mtk-src', found.url);
el.removeAttribute('src');
if (pending.indexOf(el) === -1) pending.push(el);
}
function flush() {
if (flushed) return;
flushed = true;
for (var i = 0; i < pending.length; i++) resolve(pending[i]);
pending = [];
}
function scan(node) {
if (!node || node.nodeType !== 1) return;
consider(node);
if (!node.querySelectorAll) return;
var frames = node.querySelectorAll('iframe');
for (var i = 0; i < frames.length; i++) consider(frames[i]);
}
new MutationObserver(function (muts) {
for (var i = 0; i < muts.length; i++) {
var m = muts[i];
if (m.type === 'attributes') consider(m.target);
else for (var j = 0; j < m.addedNodes.length; j++) scan(m.addedNodes[j]);
}
}).observe(document.documentElement, {
childList: true, subtree: true,
attributes: true, attributeFilter: URL_ATTRS
});
scan(document.documentElement);
(function wait(start) {
if (attributionData()) return flush();
if (Date.now() - start > MAX_WAIT_MS) return flush();
setTimeout(function () { wait(start); }, 50);
})(Date.now());
})();
</script>
<!-- End MTK Attribution GoHighLevel iframe Companion Script -->It tags every GHL form / survey iframe with the attribution values as query parameters, so GHL renders the form already prefilled — no reload, nothing to wipe. It watches for the URL wherever it appears: on src at insertion, assigned a tick later, or parked in a lazy-load data-src. Load order is therefore not critical, which is why GTM works. Iframes that aren't GHL widgets are left alone.
4b · Confirm which form is embedded. On the page with the form, run this in the console with the context set to top:
document.querySelectorAll('iframe[data-mtk-done]').forEach(function (f) {
console.log(f.getAttribute('data-src') || f.getAttribute('src'));
});The ID after /widget/form/is the form you must edit. Don't skip this. GHL sub-accounts built from a snapshot routinely contain several forms with the same name, and editing the wrong one is invisible — the builder saves happily while the embedded form never changes.
4c · Create a contact custom field per value. In the form builder, open Add Object Fields:
- Field Type: Single Line
- Field Name: the Field Name from your field map (recommended), or anything you like
- Add to object: Contact. Folder as you prefer.
Save the custom field, then drag it into the form. The field has to exist on the contact record first — a plain form element with no backing custom field will never prefill.
4d · Configure each field. Click it and set:
- Query Key — the field's HTML Name (e.g.
mtk_first_source). This is what the companion matches on, so it must be character-for-character exact. - Hidden — ticked.
Repeat for every field you want, then Save the form.
Publish and verify
Publish, then load the form page in a private window with a test URL like ?utm_source=googleads&utm_medium=cpc and submit. Check the contact record in GHL for the values.
On the iframe route, verify without submitting.Open the widget URL in its own tab — as a top-level page it isn't cross-origin, so you can inspect the form directly. Add one mtk_* parameter and one you know already works as a control:
https://api.leadconnectorhq.com/widget/form/YOUR_FORM_ID?mtk_last_source=MTKTEST&a_known_working_key=CONTROL
Then run:
console.log([...document.querySelectorAll('input')]
.filter(function (i) { return i.name !== 'cf-turnstile-response'; })
.map(function (i) { return (i.name || '(unnamed)') + ' = ' + i.value; })
.join('\n'));The control is what makes this conclusive. If it binds and your mtk_*field doesn't, the problem is that one field's Query Key or Hidden setting. If neitherbinds, you're looking at the wrong form or a stale render — go back to 4b. Without a control you can't tell those two apart, and they're fixed in completely different places.
[mtk:…]tokens into an iframe form's fields.Nothing on GHL's side replaces them, so the literal token text submits into the lead record. Tokens are for the inline route only; on the iframe route the fields stay empty and are filled from the URL.Journey JSON and Attribution Summary. Both are long enough to crowd the widget URL past its practical ceiling on their own, so they ship in the companion's EXCLUDElist — everything they contain already arrives through the individual fields. The script skips any field that would push the URL too far rather than truncating it into half a value, and says which ones in the console when it does. If you see that warning, add the longest fields you don't need to EXCLUDE— that's the whole fix.1. Are you editing the form that's actually embedded? Run the 4b check and compare that ID against the one in the form builder's address bar. This is the most common cause by a wide margin and the one that looks least like a cause — everything saves correctly, into a form nobody loads. Also confirm you're in the right sub-account.
2. Does a control parameter bind?Use the step 5 test. If the control is empty too, stop looking at your field — you're on the wrong form or a stale render.
3. Is the field on the served form?Count the inputs in the dump against the builder. A field in the builder with no counterpart in the served form means it wasn't saved.
4. Is it a real contact custom field? Its input
name should be an opaque ID like DGeHOAYSE1dqNq7kqf5b. A readable name means a plain form element that will never prefill.5. Query Key and Hidden.Parameters on the URL but an empty field means the Query Key doesn't match, or Hidden isn't ticked. Reload the builder to confirm the Query Key actually persisted.
6. Is the tag itself loading? No
mtk_attribution event in window.dataLayer means it was blocked, usually by an ad blocker — the companion then releases the form unchanged, which is why it still renders.Note that GHL field
name attributes are internal IDs, never the Query Key, so Query Keys can only be checked in the form builder. Still stuck? Troubleshooting.document.querySelectorAll("iframe[data-mtk-done]").length. The script stamps that attribute on every widget it tags, so a count of zero means it never recognized the iframe. To see the URL it built, log each frame's src and data-src. Then open that URL in its own tab — as a top-level page it's no longer cross-origin, so you can inspect the form's inputs and confirm the fields arrive filled.about:blank: WP Rocket, Perfmatters, LiteSpeed and similar rewrite an embed to src="about:blank" and park the real URL in data-src, swapping it back in on scroll. The companion reads those attributes as well as src and tags whichever one holds the widget URL, so the lazy swap loads an already-tagged URL with no reload — no need to exclude the iframe from lazy loading. Note that GHL field name attributes are internal IDs like HqxqJwQoSCZojekXhO89, never the Query Key, so you can't verify Query Keys by inspecting the DOM — check them in the form builder.