Zoho Forms
A Zoho Forms embed points an <iframe> at forms.zohopublic.com. A cross-origin iframe can't be read from the parent page, so the tag can't reach these fields. What your page does control is the iframe's src, and Zoho prefills any field whose Field Alias matches a query parameter on it. The companion script below writes those parameters in before the frame loads.
Load the tag on the page that hosts the form
The tag belongs on your site — the page the visitor is actually looking at — never inside the form. Paste the loader into the <head> on every page, or add it as a Custom HTML tag in Google Tag Manager firing on all pages.
<script src="https://attr.boggsmtk.com/v1/tag.js?k=YOUR_LICENSE_KEY"></script>
Swap YOUR_LICENSE_KEYfor the client's key from the dashboard. On Webflow that's Site settings → Custom code → Head.
Add a hidden field per value
In the form builder, drag in a Single Linefield for each value you want to capture. Open each one's properties and tick Hide Field so it never shows to the visitor.
Give the field any label that reads well to whoever reviews the entries — Last Channel, Last Source. The label is what you'll see in the entries table; it plays no part in the prefill.
Give each field a Field Alias
Go to Settings → Field Alias – Prefill URLand set each hidden field's alias to its HTML Name from your field map — mtk_last_channel, mtk_last_source, and so on.
The alias is the query parameter Zoho prefills from, and it has to match character-for-character. It never appears as an input name on the rendered form, so you can't verify it by inspecting the HTML — step 6 shows how to check it properly.
RENAME list.Add the companion script
Put it in the page's <head>, directly below the tag loader — or as its own GTM Custom HTML tag firing on all pages. Trim INCLUDE to the fields you actually built aliases for in step 3.
<!-- MTK Attribution Zoho Forms 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 EXTRA_HOSTS = [];
var HOST_RE = /(^|\.)zohopublic\.(com|eu|in|jp|ca|sa|com\.au|com\.cn)$/i;
var PATH_RE = /\/formperma\//i;
var URL_ATTRS = ['src', 'data-src', 'data-lazy-src', 'data-litespeed-src'];
var MAX_WAIT_MS = 3000;
var MAX_URL_LEN = 1800;
var DEBUG = /[?&]mtkdebug=1/.test(window.location.search);
var pending = [];
var flushed = false;
function log() {
if (!DEBUG || !window.console || !console.log) return;
console.log.apply(console, ['[MTK Zoho]'].concat([].slice.call(arguments)));
}
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 pairsFrom(data) {
var out = [];
var keys = keysFrom(data);
for (var i = 0; i < keys.length; i++) {
var key = keys[i];
var value = data[key];
if (value === undefined || value === null || value === '') continue;
out.push({ key: key, alias: RENAME[key] || key, value: String(value) });
}
return out;
}
function isFormUrl(url) {
var m = /^(?:https?:)?\/\/([^\/?#]+)([^?#]*)/.exec(url);
if (!m) return false;
var host = m[1].replace(/:\d+$/, '');
var path = m[2] || '/';
if (EXTRA_HOSTS.indexOf(host) > -1) return PATH_RE.test(path);
return HOST_RE.test(host) && PATH_RE.test(path);
}
function formUrl(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 && isFormUrl(value)) return { attr: URL_ATTRS[i], url: value };
}
return null;
}
function withAttribution(url, data) {
if (!data) {
log('no mtk_attribution event - releasing form unchanged');
return url;
}
var pairs = pairsFrom(data);
var dropped = [], sent = [];
var sep = url.indexOf('?') > -1 ? '&' : '?';
var qs = '';
for (var j = 0; j < pairs.length; j++) {
var p = pairs[j];
var pair = encodeURIComponent(p.alias) + '=' + encodeURIComponent(p.value);
if (url.length + sep.length + qs.length + pair.length + 1 > MAX_URL_LEN) {
dropped.push(p.key);
continue;
}
qs += (qs ? '&' : '') + pair;
sent.push(p.key);
}
if (dropped.length && window.console && console.warn) {
console.warn(
'[MTK Zoho] form URL hit its ' + MAX_URL_LEN + '-character ceiling; ' +
'these did not fit: ' + dropped.join(', ') +
' - trim INCLUDE or move them earlier in the list.'
);
}
log('sent ' + sent.length + ' fields:', sent.join(', '));
var out = qs ? url + sep + qs : url;
log('final URL (' + out.length + ' chars):', out);
return out;
}
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 = formUrl(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 = formUrl(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 Zoho Forms iframe Companion Script -->Because Zoho's embed ships with src already set, the browser would otherwise start fetching the untagged URL as it parses. The companion stashes that URL and drops src for the moment it takes the values to land, then puts it back with the parameters on — so the form loads once, already filled, with no reload to wipe anything.
Paste the embed code — unchanged
In the form builder, go to Share → Embed, choose iframe, and copy the code. Paste it on your page where the form should appear. It looks like this:
<iframe aria-label="Request a Quote" frameborder="0" style="height:500px;width:99%;border:none;" src="https://forms.zohopublic.com/yourorg/form/RequestAQuote/formperma/AbC123…"> </iframe>
Nothing in it needs editing — the companion finds the frame by its Zoho form URL, not by id or any marker attribute.
Publish and verify
Publish, then load the page in a private window with a test URL like ?utm_source=googleads&utm_medium=cpc, submit the form, and check the entry under Entries → All Entries in Zoho.
To check it without submitting, run this in the console with the context set to top, not the form's frame:
document.querySelectorAll('iframe[data-mtk-done]').forEach(function (f) {
console.log(f.getAttribute('data-src') || f.getAttribute('src'));
});The companion stamps data-mtk-doneon every frame it tags, so a count of zero means it never recognized the iframe. Otherwise you'll see the URL it built.
To confirm the values reach the fields, switch the console's context dropdown from top to the forms.zohopublic.comframe and run this. It reads the prefill parameters off the frame's own URL and reports which ones landed:
(function () {
var params = {};
(location.search || '').replace(/^\?/, '').split('&').filter(Boolean).forEach(function (p) {
var eq = p.indexOf('=');
if (eq < 0) return;
params[decodeURIComponent(p.slice(0, eq))] =
decodeURIComponent(p.slice(eq + 1).replace(/\+/g, ' '));
});
var fields = [].slice.call(document.querySelectorAll('input, select, textarea'));
console.table(Object.keys(params).map(function (alias) {
var hit = fields.filter(function (f) { return f.value === params[alias]; });
return {
'URL param': alias,
'expected value': params[alias],
'landed in field': hit.length ? hit.map(function (f) { return f.name || f.id; }).join(', ') : '—',
status: hit.length ? 'ok' : 'NOT PREFILLED'
};
}));
})();It matches on value, not name, because Zoho renders its inputs as SingleLine, SingleLine1, … — the alias exists only as a URL parameter. Opening the tagged URL in its own tab works too: as a top-level page it's no longer cross-origin, so the same script runs without switching context.
1. Did the companion tag the frame? Zero
data-mtk-doneiframes means it never matched the URL. A client on a Zoho data centre outside the built-in host list won't match — add the host to EXTRA_HOSTS at the top of the script.2. Are the parameters on the URL but the fields still empty? That's a Field Alias mismatch. Re-open Settings → Field Alias – Prefill URL and compare each alias character-for-character against the HTML Name.
3. Is the field actually on the published form? An alias saved against a field you never added — or a form edited but not saved — prefills nothing.
4. Is the tag itself loading? No
mtk_attribution event in window.dataLayermeans it was blocked, usually by an ad blocker or a license whose domain list doesn't cover the site — the companion then releases the form unchanged, which is why it still renders.Still stuck? Troubleshooting.
INCLUDE is a list you write, not one you trim. The engine publishes a legacy alias beside most canonical names — mtk_summary next to mtk_attribution_summary, mtk_lt_source next to mtk_last_source— so that forms built before the field rename keep filling. A block-list approach forwards every one of those twice and burns the URL budget on duplicates; an allowlist can't, because a name has to be typed in to be sent. It also fixes the drop order: when the ceiling is reached, the tail of INCLUDE goes first, so put what matters most at the top.Journey JSON and Attribution Summary. Either one can fill the form URL's 1800-character ceiling on its own, and everything they contain already arrives through the individual fields. Both are off the default INCLUDE for that reason.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 form URL, so the lazy swap loads an already-tagged URL with no reload — no need to exclude the iframe from lazy loading.referrername— the current page's URL — and nothing else. A visitor who lands on /?utm_source=googleads and then clicks through to the contact page passes a URL with no UTMs on it at all, and you get a string rather than a normalized channel and source. Replace it with the companion above rather than running both; two scripts assigning src will load the form twice.