<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
<channel>
<title><![CDATA[ TEKNKL :: Blog ]]></title>
<description><![CDATA[ /* Using, building, and yelling at SaaS platforms. Lots about Marketo. */ ]]></description>
<link>https://blog.teknkl.com</link>
<image>
    <url>https://blog.teknkl.com/favicon.png</url>
    <title>TEKNKL :: Blog</title>
    <link>https://blog.teknkl.com</link>
</image>
<lastBuildDate>Fri, 16 Oct 2026 13:14:48 -0400</lastBuildDate>
<atom:link href="https://blog.teknkl.com" rel="self" type="application/rss+xml"/>
<ttl>60</ttl>

    <item>
        <title><![CDATA[ Bulk export Marketo notifications with this UI hack ]]></title>
        <description><![CDATA[ In the Notifications UI (🕫 icon) you can drill into SFDC Sync errors, LaunchPoint warnings, and other exceptions one-by-one. Add bulk export with a little JS. ]]></description>
        <link>https://blog.teknkl.com/bulk-export-marketo-notifications-using-this-ui-hack/</link>
        <guid isPermaLink="false">6ab83e58036edb0001125c6b</guid>
        <category><![CDATA[ notifications ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Sat, 26 Sep 2026 23:45:53 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/09/ui_hacks.png" medium="image"/>
        <content:encoded><![CDATA[ <p>In the Notifications UI, you can drill into SFDC Sync and other errors one-by-one: </p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/09/image-11.png" class="kg-image" alt="" loading="lazy" width="1918" height="1009" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2026/09/image-11.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2026/09/image-11.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1600/2026/09/image-11.png 1600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/09/image-11.png 1918w" sizes="(min-width: 720px) 720px"></figure><p>But unfortunately there’s no bulk export, making it harder to track trends &amp; fixes.</p><h2 id="hacker%E2%80%99s-delight">Hacker’s delight</h2><p>Conveniently, the underlying (private) endpoint <code>/data/inboxRecord/retrieve</code> supports <em>get-everything </em>requests, not just <em>get-summaries </em>and <em>get-single-details</em>. </p><p>That is, though the UI lists only summaries – you click a summary to load its details – it’s easy to get the whole notifications “inbox” for offline crunching. Just open your browser console, click any notification in the list or the details pane<a href="#notes" rel="noreferrer">[1]</a>, and run this JS: </p><pre><code class="language-javascript">{
    const sanitizer = new Sanitizer({
      removeElements: ["br"],
      attributes: ["class","id"],
      dataAttributes: true
    });
    
    const currentURL = new URL(document.location.href);
    
    const notificationsInboxURL = new URL(currentURL.origin);
    notificationsInboxURL.pathname = "/data/inboxRecord/retrieve";
    
    const payload = new URLSearchParams([
    	["sort", JSON.stringify([{ property: "timestamp", direction: "DESC" }])],
    	["fields", JSON.stringify(["id","classType","type","notificationSubtype","subject","timestamp","status","body"])],
    	["context", "NO"],
    	["uShell", "true"],
    	["xOrigin", "true"],
    	["symfony", currentURL.searchParams.get("symfony") ],
    	["xsrfId", Mkt3.Config.getEnv('xsrfId')],
    	["ids_sso", Mkt3.Config.getEnv('ssoId') ]
    ]);
    
    fetch(notificationsInboxURL, {
      "headers": {
        "content-type": "application/x-www-form-urlencoded; charset=UTF-8",
      },
      "body": payload.toString(),
      "method": "POST",
      "mode": "cors",
      "credentials": "omit"
    })
    .then( (resp) =&gt; resp.json() )		
    .then( (inbox) =&gt; inbox.data.map( (values) =&gt; {
        const assembled = {};
        values.forEach( (value,idx) =&gt; assembled[inbox.fields[idx]] = value );
        const tmpl = document.createElement("template");
        tmpl.setHTML(assembled.body,{sanitizer});
        assembled.body = tmpl.innerHTML;
        return assembled;
    }) )
    .then( console.log )
}</code></pre><p>That’ll dump the full inbox to the console, and if you copy it<a href="#notes" rel="noreferrer">[2]</a>:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/09/image-12.png" class="kg-image" alt="" loading="lazy" width="1696" height="454" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2026/09/image-12.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2026/09/image-12.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1600/2026/09/image-12.png 1600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/09/image-12.png 1696w" sizes="(min-width: 720px) 720px"></figure><p>You get a JSON array shaped like this:</p><pre><code class="language-json">[
  {
    "id": 638245,
    "classType": "info",
    "type": "linked_in_lead_gen_failure",
    "notificationSubtype": "linkedin_failure",
    "subject": "LinkedIn Lead Gen sync error : Mismatched datatype for field mapped",
    "body": "&lt;div&gt;\n  &lt;div&gt;\n    &lt;div id=\"main_title\"&gt;    &lt;/div&gt;\n  &lt;/div&gt;\n  \n  \n\n  &lt;div id=\"main_text\"&gt;\n          \n          \n\n          &lt;span&gt;Why: &lt;/span&gt;\n          \n          &lt;span&gt;linkedIn field name: &lt;/span&gt;WORK_EMAIL\n          \n          &lt;span&gt;marketo field name: &lt;/span&gt;Email Address\n          \n          &lt;span&gt;datatype: &lt;/span&gt;email\n          \n          &lt;span&gt;field value: &lt;/span&gt;swhiteman @example.com\n          \n          &lt;span&gt;adFormResponseId: &lt;/span&gt;4721eb0d-5d42-4d82-a65e-2f3b7010f14b-3\n          \n          &lt;span&gt;formId: &lt;/span&gt;1003116022\n          \n          &lt;span&gt;failure reason:&lt;/span&gt;Received invalid email from LinkedIn.\n\n          \n          \n          &lt;span&gt;What to do now? &lt;/span&gt;\n          The Marketo field (Email Address) expects a valid email address.Make sure your LinkedIn form containing the (WORK_EMAIL) field requests a valid email address.\n          \n          If (WORK_EMAIL) is not expected to have the above response, go to LinkedIn Lead Gen Launchpoint:\n          \n          1) Edit the Launchpoint.\n          \n          2) In field mappings dialog, remap the LinkedIn field name (WORK_EMAIL) to a Marketo field which is of String datatype.\n          \n          \n    \n    \n  &lt;/div&gt;\n&lt;/div&gt;",
    "timestamp": "2026-09-26 15:41:56",
    "status": "unread"
},
{
    "id": 638216,
    "classType": "error",
    "type": "salesforce_sync_failure",
    "notificationSubtype": "sfdc_sync",
    "subject": "Salesforce Sync Error: Invalid Cross Reference Key",
    "body": "&lt;div&gt;\n    &lt;div&gt;\n        &lt;div id=\"main_title\"&gt;Salesforce Sync Error&lt;/div&gt;\n    &lt;/div&gt;\n    \n          &lt;span&gt;Why: &lt;/span&gt;Salesforce CampaignMember creation failed due to invalid cross reference key error\n      \n      \n      &lt;span&gt;Error details:&lt;/span&gt;\n      Received error message from salesforce while creating CampaignMember: INVALID_CROSS_REFERENCE_KEY, invalid cross reference id\n      \n      \n      &lt;span&gt;What to do now? &lt;/span&gt;\n      Contact your Salesforce administrator for further troubleshooting\n      \n      &lt;/div&gt;",
    "timestamp": "2026-09-26 10:12:01",
    "status": "read"
},
{
    "id": 638147,
    "classType": "error",
    "type": "salesforce_sync_failure",
    "notificationSubtype": "sfdc_sync",
    "subject": "Salesforce Sync Error: Unable to update Person",
    "body": "&lt;div&gt;\n  &lt;div&gt;\n    &lt;div id=\"main_title\"&gt;Salesforce Sync Error&lt;/div&gt;\n  &lt;/div&gt;\n  \n      &lt;span&gt;Why: &lt;/span&gt;Marketo is unable to update Lead in Salesforce.\n    \n    \n    &lt;span&gt;Error details: &lt;/span&gt;\n    INSUFFICIENT_ACCESS_OR_READONLY: insufficient access rights on object id    \n    \n    &lt;span&gt;Sample People:&lt;/span&gt;\n    &lt;span&gt;Showing 3 of total 3 affected Leads\n    &lt;/span&gt;\n    \n    &lt;div&gt;\n      &lt;table&gt;            &lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;a&gt;swhiteman@example.org&lt;/a&gt;&lt;/td&gt;&lt;/tr&gt;            &lt;tr&gt;&lt;td&gt;&lt;a&gt;rodger.dodger@example.ch&lt;/a&gt;&lt;/td&gt;&lt;/tr&gt;            &lt;tr&gt;&lt;td&gt;&lt;a&gt;jobert@example.com&lt;/a&gt;&lt;/td&gt;&lt;/tr&gt;      &lt;/tbody&gt;&lt;/table&gt;\n    &lt;/div&gt;\n    \n    &lt;span&gt;What to do now? &lt;/span&gt;\n    Contact your Salesforce administrator for further troubleshooting\n    \n    &lt;/div&gt;",
    "timestamp": "2026-09-26 01:32:18",
    "status": "read"
},
/* ... more entries... */
]
</code></pre><h2 id="going-further">Going further</h2><p>With this code, the HTML in the <code>body</code> property is simplified by removing <code>&lt;br&gt;</code> and <code>style</code> attributes, but that’s it. </p><p>You <em>could </em>also leave <code>body</code> as a Document Fragment and traverse it to create a nested JS object. Thing is, the HTML is unstructured, i.e. different error <code>type</code>s have different HTML shapes. So it’d be pretty hairy to work out the semantics for each error, especially those that haven’t shown up in my test instances! You’re welcome to give it a shot and let me know in the comments.😃 </p><h2 id="it%E2%80%99s-a-hack-but-that%E2%80%99s-okay">It’s a hack, but that’s okay</h2><p>This code, like my other UI hacks, is <em>0% supported </em>by Adobe: the underlying private API endpoint is subject to change at any time. But hopefully, if this endpoint changes bulk export will also be natively available!</p><h5 id="notes">Notes</h5>
<p>[1] This focuses the inner IFRAME, which is where the <code>fetch</code> must originate. You can also pick <code>app-&lt;nnnn&gt;.marketo.com</code> from the JS context chooser:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/09/image-13.png" class="kg-image" alt="" loading="lazy" width="736" height="685" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2026/09/image-13.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/09/image-13.png 736w" sizes="(min-width: 720px) 720px"></figure><p></p><p>[2] For this case, I assume you’ll copy-paste into a text editor. Automatically downloading a <code>.json</code> file is left as an exercise for the reader (hint: see <a href="https://blog.teknkl.com/export-all-flow-steps-json-file-backup/" rel="noreferrer">my other UI hack posts</a>).</p><h2 id=""></h2> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Toggle WebFonts on/off in Email Editor to simulate different mail clients ]]></title>
        <description><![CDATA[ WebFonts work perfectly in Apple Mail, which comprises at least 20% of opens. They emphatically don’t work anywhere else, but with proper fallbacks they’re worth exploring. ]]></description>
        <link>https://blog.teknkl.com/toggle-webfonts-on-off-in-email-editor-to-simulate-different-mail-clients/</link>
        <guid isPermaLink="false">6a99d5552496a900013996c4</guid>
        <category><![CDATA[ Marketo ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Fri, 04 Sep 2026 00:25:24 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/09/emailtemplate.png" medium="image"/>
        <content:encoded><![CDATA[ <div class="kg-card kg-callout-card kg-callout-card-yellow"><div class="kg-callout-emoji">💡</div><div class="kg-callout-text">This solution uses the same tech as <a href="https://blog.teknkl.com/toggle-images-on-off-in-email-editor-to-simulate-different-clients/" rel="noreferrer">toggling images in Email Editor</a>. I’ll omit some details you can read about over there.</div></div><p>You might be surprised that WebFonts (via CSS <code>@font-face</code>) are supported in <a href="https://www.caniemail.com/features/css-at-font-face/?ref=blog.teknkl.com" rel="noreferrer">Apple Mail</a> for desktop and mobile. Those apps comprise at least 20% of email opens; using some methodologies, <a href="https://www.litmus.com/email-client-market-share?ref=blog.teknkl.com" rel="noreferrer">as much as 60%</a>. Even the low end, IMO, doesn’t match the common take that “few” end users will see your fancy fonts!</p><p>Yes, you must <a href="https://www.emailonacid.com/blog/article/email-development/custom-font-stacks-in-outlook/?ref=blog.teknkl.com" rel="noreferrer">fallback forcefully for Outlook</a>. But it’s totally doable: this month (August 2026) I’ve seen WebFont-enabled emails from CDW, Dolby, London Review of Books, Malwarebytes, Mural, Salesforce, Verizon, and Zapier.</p><p>As with <a href="https://blog.teknkl.com/toggle-images-on-off-in-email-editor-to-simulate-different-clients/" rel="noreferrer">toggling images</a>, you can easily add a WebFonts toggle to Email Editor 2.0: </p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/09/image-8.png" class="kg-image" alt="" loading="lazy" width="463" height="307"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/09/image-9.png" class="kg-image" alt="" loading="lazy" width="1814" height="991" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2026/09/image-9.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2026/09/image-9.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1600/2026/09/image-9.png 1600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/09/image-9.png 1814w" sizes="(min-width: 720px) 720px"></figure><p>A little Email 2.0 template code high up in <code>&lt;head&gt;</code>:</p><pre><code class="language-markup">&lt;meta class="mktoBoolean" id="enableImages" mktoname="Enable WebFonts" true_value="http:" false_value="'none'" default="true" /&gt;    
&lt;meta http-equiv="Content-Security-Policy" content="font-src ${enableWebFonts}"&gt;</code></pre><p>And this <code>&lt;style&gt;</code> right after:</p><pre><code class="language-markup">&lt;style&gt;
:root:has(meta[http-equiv="Content-Security-Policy"][content="img-src 'none'"]) {
  --images-disabled: "images";
}
:root:has(meta[http-equiv="Content-Security-Policy"][content="font-src 'none'"]) {
  --webfonts-disabled: "webfonts";
}
:root:has(meta[http-equiv="Content-Security-Policy"][content="img-src 'none'"]),
:root:has(meta[http-equiv="Content-Security-Policy"][content="font-src 'none'"]) {
  body:before {
    font: small-caption;
    display: inline-block;
    padding: 6px;
    content: "⚠️ Resources disabled for testing: " var(--images-disabled,) " " var(--webfonts-disabled,);
  }
}
&lt;/style&gt;</code></pre><p>(Same <code>&lt;style&gt;</code> used for the <a href="https://blog.teknkl.com/toggle-images-on-off-in-email-editor-to-simulate-different-clients/" rel="noreferrer">image toggle</a> — no need to add it twice!)</p><h2 id="how-it-works">How it works</h2><p>Same as the <a href="https://blog.teknkl.com/toggle-images-on-off-in-email-editor-to-simulate-different-clients/" rel="noreferrer">image toggle</a>.😉</p><h2 id="something-similar-in-email-editor-30">Something similar in Email Editor 3.0</h2><p>Still disappointing that the <mark>new editor doesn’t yet support variables</mark> yet. Meantime, create a custom String field <em>Test Lead for Mail Client</em>. For a dedicated test lead, set it to “Apple Mail”.</p><p>Put this Velocity <code>{{my.token}}</code> high up in the template <code>&lt;head&gt;</code>:</p><pre><code class="language-velocity">#if( $lead.testLeadForMailClient.equals("Apple Mail") )
&lt;meta http-equiv="Content-Security-Policy" content="img-src 'none'" /&gt;
#elseif( !$lead.testLeadForMailClient.isEmpty() )
&lt;meta http-equiv="Content-Security-Policy" content="img-src http:" /&gt;
#end</code></pre><p>Add the same <code>&lt;style&gt;</code> tag as in the Editor 2.0 version:</p><pre><code class="language-markup">&lt;style&gt;
:root:has(meta[http-equiv="Content-Security-Policy"][content="img-src 'none'"]) {
  --images-disabled: "images";
}
:root:has(meta[http-equiv="Content-Security-Policy"][content="font-src 'none'"]) {
  --webfonts-disabled: "webfonts";
}
:root:has(meta[http-equiv="Content-Security-Policy"][content="img-src 'none'"]),
:root:has(meta[http-equiv="Content-Security-Policy"][content="font-src 'none'"]) {
  body:before {
    font: small-caption;
    display: inline-block;
    padding: 6px;
    content: "⚠️ Resources disabled for testing: " var(--images-disabled,) " " var(--webfonts-disabled,);
  }
}
&lt;/style&gt;</code></pre><p>And preview by your test lead:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/09/image-6.png" class="kg-image" alt="" loading="lazy" width="1361" height="1208" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2026/09/image-6.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2026/09/image-6.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/09/image-6.png 1361w" sizes="(min-width: 720px) 720px"></figure><p>That’s it!</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Toggle images on/off in Email Editor to simulate different mail clients ]]></title>
        <description><![CDATA[ Save yourself a few “Send Sample” runs by previewing without images. ]]></description>
        <link>https://blog.teknkl.com/toggle-images-on-off-in-email-editor-to-simulate-different-clients/</link>
        <guid isPermaLink="false">6a97bc6d9382da000107b57d</guid>
        <category><![CDATA[ Marketo ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Thu, 03 Sep 2026 02:03:12 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/09/emailtemplate.png" medium="image"/>
        <content:encoded><![CDATA[ <p>We all know Outlook blocks images by default, yet there’s no native way to <em>preview without images </em>in the Marketo UI. Turns out it’s super-easy to add a toggle to Email Editor 2.0:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/09/image-3.png" class="kg-image" alt="" loading="lazy" width="463" height="322"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/09/image-7.png" class="kg-image" alt="" loading="lazy" width="1811" height="782" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2026/09/image-7.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2026/09/image-7.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1600/2026/09/image-7.png 1600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/09/image-7.png 1811w" sizes="(min-width: 720px) 720px"></figure><p>Only takes a little Email 2.0 template code high up in <code>&lt;head&gt;</code>:</p><pre><code class="language-markup">&lt;meta class="mktoBoolean" id="enableImages" mktoname="Enable Images" true_value="http:" false_value="'none'" default="true" /&gt;    
&lt;meta http-equiv="Content-Security-Policy" content="img-src ${enableImages}"&gt;</code></pre><p>And this <code>&lt;style&gt;</code> right after:</p><pre><code class="language-markup">&lt;style&gt;
:root:has(meta[http-equiv="Content-Security-Policy"][content="img-src 'none'"]) {
  --images-disabled: "images";
}
:root:has(meta[http-equiv="Content-Security-Policy"][content="font-src 'none'"]) {
  --webfonts-disabled: "webfonts";
}
:root:has(meta[http-equiv="Content-Security-Policy"][content="img-src 'none'"]),
:root:has(meta[http-equiv="Content-Security-Policy"][content="font-src 'none'"]) {
  body:before {
    font: small-caption;
    display: inline-block;
    padding: 6px;
    content: "⚠️ Resources disabled for testing: " var(--images-disabled,) " " var(--webfonts-disabled,);
  }
}
&lt;/style&gt;</code></pre><p>(As you might’ve figured, this <code>&lt;style&gt;</code> is also used in the companion post on disabling WebFonts, which is why I separated it out.)</p><h2 id="how-it-works">How it works</h2><p><code>&lt;meta class="mktoBoolean"&gt;</code> creates a switch widget for a global Marketo variable.</p><p>That variable <code>${enableImages}</code> is used in a <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/img-src?ref=blog.teknkl.com" rel="noreferrer">Content-Security-Policy</a> <code>&lt;meta&gt;</code> tag that controls allowed image URLs, flipping between <code>'none'</code> (uh, none) and <code>https:</code> (implicitly allowing all).</p><p>The <code>&lt;style&gt;</code> adds a visual reminder that images aren’t accidentally broken, they’re deliberately disabled. Shockingly simple, right?</p><p>(You should know much of CSP can be implemented via HTML <code>&lt;meta&gt;</code> tags, not just via HTTP headers. Support <a href="https://www.w3.org/TR/CSP3/?ref=blog.teknkl.com#meta-element" rel="noreferrer">isn’t as broad as with HTTP</a>, but it works fine for <code>img-src</code> as long as the <code>&lt;meta&gt;</code> is parsed before images.)</p><h2 id="something-similar-in-email-editor-30">Something similar in Email Editor 3.0</h2><p>The newer <mark>editor doesn’t yet support variables</mark>, an oversight I assume will be corrected soon. Meantime, create a custom String field <em>Test Lead for Mail Client</em>. It’s empty by default, but for a dedicated test lead, set it to “Outlook”.</p><p>Put this Velocity <code>{{my.token}}</code> high up in the template <code>&lt;head&gt;</code>:</p><pre><code class="language-velocity">#if( $lead.testLeadForMailClient.equals("Outlook") )
&lt;meta http-equiv="Content-Security-Policy" content="img-src 'none'" /&gt;
#elseif( !$lead.testLeadForMailClient.isEmpty() )
&lt;meta http-equiv="Content-Security-Policy" content="img-src http:" /&gt;
#end</code></pre><p>Add the same <code>&lt;style&gt;</code> tag as in the Editor 2.0 version:</p><pre><code class="language-markup">&lt;style&gt;
:root:has(meta[http-equiv="Content-Security-Policy"][content="img-src 'none'"]) {
  --images-disabled: "images";
}
:root:has(meta[http-equiv="Content-Security-Policy"][content="font-src 'none'"]) {
  --webfonts-disabled: "webfonts";
}
:root:has(meta[http-equiv="Content-Security-Policy"][content="img-src 'none'"]),
:root:has(meta[http-equiv="Content-Security-Policy"][content="font-src 'none'"]) {
  body:before {
    font: small-caption;
    display: inline-block;
    padding: 6px;
    content: "⚠️ Resources disabled for testing: " var(--images-disabled,) " " var(--webfonts-disabled,);
  }
}
&lt;/style&gt;</code></pre><p>And preview by (sorry, “simulate content for”) your test lead:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/09/image-6.png" class="kg-image" alt="" loading="lazy" width="1361" height="1208" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2026/09/image-6.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2026/09/image-6.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/09/image-6.png 1361w" sizes="(min-width: 720px) 720px"></figure><p>Same underlying use of CSP <code>&lt;meta&gt;</code>; not as pretty as the Email Editor 2.0 solution, but it works! </p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ You should have one (1) rule in Form Editor » Custom CSS ]]></title>
        <description><![CDATA[ The Custom CSS box is good for one thing, and that’s loading external stylesheets. But you have to do it right. ]]></description>
        <link>https://blog.teknkl.com/you-should-have-one-1-rule-in-form-editor-custom-css/</link>
        <guid isPermaLink="false">6a873d57741bc20001e1e903</guid>
        <category><![CDATA[ Marketo forms ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Thu, 20 Aug 2026 13:50:02 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/08/css_cssom.png" medium="image"/>
        <content:encoded><![CDATA[ <p>Only so many “banes of your existence” before the term is meaningless. But form-level Custom CSS (<em>Form Settings » Form Theme » </em>⚙) makes the cut for me. That Custom CSS box enables multiple bugs:</p><ol><li>Styles <em>meant for all forms </em>are isolated within the form. They persist if cloned, but are lost when someone starts a form from scratch (and they will!). </li><li>Styles <em>meant for one or a subset of forms </em>are erroneously cloned to other forms, where they have to be overridden by sloppy <code>!important</code> rules.</li><li>Style <em>changes aren’t propagated,</em> by definition. It’s impossible to know whether a form uses the latest CSS. </li></ol><div class="kg-card kg-callout-card kg-callout-card-yellow"><div class="kg-callout-emoji">💡</div><div class="kg-callout-text">To target a specific form (in the business sense) don’t select form IDs in CSS, e.g. <code spellcheck="false" style="white-space: pre-wrap;">#mktoForm_nnnn.</code> That’ll break when somebody clones the form to make changes. Instead, assign a data-attribute like <code spellcheck="false" style="white-space: pre-wrap;">&lt;form data-form-name="product-registration"&gt;</code> to find the form regardless of ID.</div></div><p>In my scarily long experience, it’s <mark>impossible to coordinate Custom CSS with page-level and site-level <code>&lt;style&gt;</code>s and <code>&lt;link&gt;</code>s</mark>. The correct place for form styles is an <strong>external <code>.css</code> stylesheet.</strong></p><h2 id="conventional-approach-custom-css-is-empty-link-loads-external-styles">Conventional approach: Custom CSS is empty, <code>&lt;link&gt;</code> loads external styles</h2><p>You can simply <code>&lt;link&gt;</code> external styles before calling <code>loadForm()</code>: </p><pre><code class="language-markup">&lt;link rel="stylesheet" href="https://pages.example.com/rs/123-ABC-456/images/forms-core.css"&gt;
&lt;script src="//pages.example.com/js/forms2/js/forms2.min.js"&gt;&lt;/script&gt;
&lt;form id="mktoForm_12213"&gt;&lt;/form&gt;
&lt;script&gt;MktoForms2.loadForm("//pages.example.com", "123-ABC-456", 12213);&lt;/script&gt;</code></pre><p>On Marketo LPs, a variable or token allows staged rollouts of new styles:</p><pre><code class="language-markup">&lt;link rel="stylesheet" href="https://pages.example.com/rs/123-ABC-456/images/forms-core-${stylesVersion}.css"&gt;
&lt;script src="//pages.example.com/js/forms2/js/forms2.min.js"&gt;&lt;/script&gt;
&lt;form id="mktoForm_12213"&gt;&lt;/form&gt;
&lt;script&gt;MktoForms2.loadForm("//pages.example.com", "123-ABC-456", 12213);&lt;/script&gt;</code></pre><p><code>&lt;link&gt;</code> is <strong>render-blocking</strong> by default, so with the above code, there’s <em>zero chance </em>of styles not being applied the moment the form appears. <mark>You <em>do </em>want style loading to <strong>block <em>form </em>rendering</strong></mark>, otherwise the form restyles in a (sometimes) user-perceptible way, i.e. <a href="https://en.wikipedia.org/wiki/Flash_of_unstyled_content?ref=blog.teknkl.com" rel="noreferrer">FOUC</a>.<a href="#notes" rel="noreferrer">[1]</a> </p><p>Bad news is <code>&lt;link&gt;</code>s <strong>block <em>all </em>rendering</strong><em>, </em>not just resources related to the form. Stylesheets <em>usually </em>download + parse fast enough, especially if hosted in Design Studio/Cl0udflare, users won’t notice a delay. But in extreme cases (slow servers and/or connections) the delay is user-facing. </p><p>The <code>media</code>-switch hack<a href="#notes" rel="noreferrer">[2]</a> makes a <code>&lt;link&gt;</code> effectively async: </p><pre><code class="language-markup">&lt;link rel="stylesheet" href="https://pages.example.com/rs/123-ABC-456/images/forms-core.css" media="(--nonblocking)" onload="removeAttribute('media')"&gt;</code></pre><p>But then, by design, the form might render before styles are ready. Hmm.</p><h2 id="better-approach-custom-css-with-import-tiny-companion-style-in-the-page">Better approach: Custom CSS with <code>@import</code> + tiny companion <code>&lt;style&gt;</code> in the page</h2><p>Stylesheets can load other stylesheets via <code>@import</code>. So on a basic level, the Custom CSS box doesn’t need to have <em>any </em>style rules. It can have just one <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Syntax/At-rules?ref=blog.teknkl.com" rel="noreferrer">at-rule</a>:</p><pre><code class="language-css">@import "https://pages.example.com/rs/123-ABC-456/images/forms-core.css"; </code></pre><p>But there’s still a problem! Forms 2.0 injects a <code>&lt;style&gt;</code> element with the Custom CSS into <code>&lt;head&gt;</code> before injecting the <code>&lt;form&gt;</code>, but that only guarantees <em>DOM mutation </em>is ordered. It doesn’t guarantee rules <em>inside </em>the <code>&lt;style&gt;</code> take effect synchronously. Browsers even differ on whether <code>@import</code><mark> in script-injected <code>&lt;styles&gt;</code> is synchronous or not.</mark><a href="#notes" rel="noreferrer">[3]</a> </p><p>So depending on browser quirks, users can see a flash of default-styled content:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/08/customcss05-5.webp" class="kg-image" alt="" loading="lazy" width="727" height="465" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2026/08/customcss05-5.webp 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/08/customcss05-5.webp 727w" sizes="(min-width: 720px) 720px"></figure><p>For the recording, I <strong><mark>deliberately overdramatized</mark></strong> the effect of async styling by adding a 500ms delay to the <code>@import</code>-ed stylesheet. This isn’t realistic — you won’t see performance that bad from Design Studio assets — but ensures you see what I’m blathering about.😃 </p><p>The solution is this <code>&lt;style&gt;</code> before the form embed: </p><pre><code class="language-markup">&lt;style&gt;
  #mktoStyleLoaded { border-top-color: #000000 !important; }
&lt;/style&gt;
</code></pre><p>Then in your <code>forms-core.css</code> (doesn’t have to be at the top, but that’s clearest):</p><pre><code class="language-css">#mktoStyleLoaded { border-top-color: #123456 !important; }</code></pre><h2 id="why-does-it-work">Why does it work?</h2><p>Connecting a new <code>&lt;style&gt;</code> to the DOM doesn’t tell you when styles are <em>applied</em>. (In contrast, <code>&lt;link&gt;</code> and <code>&lt;style&gt;</code> in the initial HTML leave no mystery about when they take effect.) A <code>load</code> listener on the <code>&lt;style&gt;</code> would do it, but we can’t get inside the Forms 2.0 library to add that.</p><p>Luckily, the library has internal polling logic to wait for standard CSS + theme CSS to be applied before showing the form. Notice how those built-in styles aren’t affected by worries about custom styles?</p><p>We can leverage that logic. A “sentinel” style that tells the library styles are ready is <code>border-top-color: #123456</code>. (This style is on a hidden <code>&lt;div&gt;</code>, never shown to the outside world.) By taking control of that style, the form’ll wait for our stylesheet to be loaded and active.<a href="#notes" rel="noreferrer">[4]</a> Presto!</p><h2 id="yes-you-could-do-the-same-thing-with-a-media-switched-link">Yes, you could do the same thing with a <code>media</code>-switched <code>&lt;link&gt;</code> </h2><p>You can do the <code>#mktoStyleLoaded</code> trick with a <code>&lt;link&gt;</code>’d stylesheet, too. Doesn’t <em>have to </em>be <code>@import</code>’d in Custom CSS. But once that one-line <code>&lt;style&gt;</code> is part of your standard embed code, you won’t have to ask IT to make changes later and can <code>@import</code> additional stylesheets on your own.</p><h5 id="notes">Notes</h5>
<p>[1] It’s sometimes okay if styles load after the form, long as it’s <em>very soon </em>after. If styles are <mark><em>only</em> for error messages or fields shown via Visibility Rules</mark>, those aren’t necessary on initial render. Still, you shouldn’t knowingly introduce a race condition.</p><p>[2] The typical maneuver is setting <code>&lt;link media="none"&gt;</code> or <code>&lt;link media="print"&gt;</code> and switching to <code>media="all"</code> on load. But <code>none</code> is invalid per <a href="https://drafts.csswg.org/mediaqueries/?ref=blog.teknkl.com#media-type" rel="noreferrer">spec</a>, and <code>print</code> is misleading. I prefer <code>media</code> that’s syntactically valid but private: <code>media="(--nonblocking)"</code> is a feature check for a <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/ident?ref=blog.teknkl.com" rel="noreferrer">valid<code>&lt;ident&gt;</code></a>, but <code>--nonblocking</code> will never be standard ’cause it starts with <code>--</code>.</p><p>N.B. <code>media</code>-switch uses JS. That li’l <code>onload</code> could be blocked by CSP unless you allow it with <code>unsafe-inline</code> or <a href="https://content-security-policy.com/unsafe-hashes/?ref=blog.teknkl.com" rel="noreferrer"><code>unsafe-hashes</code></a>. In fairness though, Forms 2.0 isn’t built for ultra-strict CSP environments. Can’t really have it both ways, “We control every piece of code” and “Marketo handles our forms”!</p><p>[3] In Blink (Chrome/Edge) a script-injected <code>@import</code> becomes asynchronous, so rules <em>after </em><code>@import</code> take effect while the external sheet is still loading. In Firefox, script-injected <code>@import</code> remains synchronous. As usual, Webkit (Safari) uses obscure prerendering logic to avoid FOUC, which serves mostly to mislead you about standards-based behavior.</p><p>[4] Astute/awake readers will note <code>forms-core.css</code> becomes the sole styling dependency for the form (<em>replacing </em><code>forms2.css</code> and <code>forms2-theme-&lt;theme&gt;.css</code>). There’s another way to hack through that, but provided all <code>.css</code> files are in Design Studio you’ll be fine 99.9999% of the time as the default files start loading first.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Your choice: Scylla, Charybdis, or returning 𝑛𝑢𝑙𝑙 in Velocity ]]></title>
        <description><![CDATA[ Velocity loosens Java’s rules about types and nulls, but that causes more confusion than good. ]]></description>
        <link>https://blog.teknkl.com/your-choice-scylla-charybdis-or-returning-in-velocity/</link>
        <guid isPermaLink="false">6a63a11346de8600014cc596</guid>
        <category><![CDATA[ velocity ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Mon, 27 Jul 2026 02:12:29 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/07/velocity-marketo-logo-2.png" medium="image"/>
        <content:encoded><![CDATA[ <p>Velocity’s quirky treatment of <code>null</code> could drive a mortal mad.</p><p>As a language aimed at web developers rather than “real” developers, VTL attempts to smooth over Java’s strict typing: it allows comparison across types<a href="#notes" rel="noreferrer">[1]</a> and blends <code>false</code> and <code>null</code> into a form of <em>falsiness.</em> We can work with or around those features.</p><p>But it gets weirder: in versions including Marketo’s install<a href="#notes" rel="noreferrer">[2]</a>, Velocity also <mark>prohibits creating <em>new </em><code>null</code> references</mark> at runtime. There’s no real workaround for this one; ironically, to avoid related bugs, you must follow <em>poor </em>programming practices!</p><p>Take a person with a single Opportunity:</p><table>
<thead>
<tr>
<th style="text-align:left">Field</th>
<th style="text-align:left">Datatype</th>
<th style="text-align:left">Value</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align:left">LodgingType</td>
<td style="text-align:left">String</td>
<td style="text-align:left">"commercial hotel"</td>
</tr>
<tr>
<td style="text-align:left">Host</td>
<td style="text-align:left">String</td>
<td style="text-align:left">"&nbsp;&nbsp;Zaan Hotel&nbsp;&nbsp;"</td>
</tr>
<tr>
<td style="text-align:left">PointsProgram</td>
<td style="text-align:left">String</td>
<td style="text-align:left">null</td>
</tr>
<tr>
<td style="text-align:left">City</td>
<td style="text-align:left">String</td>
<td style="text-align:left">"&nbsp;Amsterdam&nbsp;"</td>
</tr>
<tr>
<td style="text-align:left">Meals</td>
<td style="text-align:left">Boolean</td>
<td style="text-align:left">false</td>
</tr>
</tbody>
</table>
<p>(Whitespace around the <code>LodgingType</code> and <code>City</code> is deliberate. And <code>null</code> is really <code>null</code>, <em>not </em>an empty string.) </p><p>Here’s the raw output of <code>${OpportunityList}</code>: </p><pre><code class="language-markup">{LodgingType=commercial hotel, Host=  Zaan Hotel  , PointsProgram=null, City= Amsterdam , Meals=false}</code></pre><p>Knowing your CRM data is messy — that’s how the whitespace crept in — you decide to trim all Strings using the regex from <a href="https://blog.teknkl.com/properly-trimming-strings-in-velocity-takes-more-than-trim/" rel="noreferrer">my recent post on trimming</a>: </p><pre><code class="language-velocity">#foreach( $oppty in $OpportunityList )
  #foreach( $entry in $oppty.entrySet() )
    #set( $key = $entry.getKey() )
    #set( $val = $entry.getValue() )
    #if( $val.class.name.equals("java.lang.String") )
      #set( $oppty[$key] = $val.replaceAll("^[\h\v]+|[\h\v]+${esc.d}","") )
    #end
  #end
#end</code></pre><p>Before clicking <strong>View Output </strong>below, try to guess what the new output of <code>${OpportunityList}</code> will be. Got an idea? OK, now click:</p><details><summary style="list-style-position: outside;
    display: list-item;" id="id"><h3>View Output</h3></summary>
    <pre class="language-markup"><code>{LodgingType=commercial hotel, Host=Zaan Hotel, PointsProgram=Zaan Hotel, City=Amsterdam, Meals=false}</code></pre>
</details>
<p><strong>Wha???</strong> Trimming worked, but <code>PointsProgram</code> is now <code>"Zaan Hotel"</code> — same as <code>Host</code>. That ain’t right and will vastly screw up any further logic.</p><h2 id="the-culprit-being-too-professional">The culprit: being too professional</h2><p>Look at line 4 above:</p><pre><code class="language-velocity">#set( $val = $entry.getValue() )</code></pre><p>A seemingly innocent assignment of the current Map value to an intermediate variable <code>$val</code>. If the current value is <code>null</code>, then <code>$val</code> will be <code>null</code>, right? <strong>Nope! </strong>Y<mark>ou cannot assign (nor reassign) <code>null</code> to a reference</mark>. The attempt is silently ignored and <code>$val</code> keeps its previous value: the last non-<code>null</code> value in the loop, <code>"  Zaan Hotel   "</code>! (Which then gets trimmed.)</p><p>In sum, while Velocity honors existing <code>null</code>s<a href="#notes" rel="noreferrer">[3]</a>, it hates new <code>null</code>s. This makes it harder to write readable, efficient code. <mark>If you do the <em>unprofessional </em>thing for readability and resource utilization</mark><a href="#notes" rel="noreferrer"><mark>[4]</mark></a><mark> and skip the intermediate variable <code>$val</code></mark>:</p><pre><code class="language-velocity">#foreach( $oppty in $OpportunityList )
  #foreach( $entry in $oppty.entrySet() )
    #set( $key = $entry.getKey() )
    #if( $entry.getValue().class.name.equals("java.lang.String") )
      #set( $oppty[$key] = $entry.getValue().replaceAll("^[\h\v]+|[\h\v]+${esc.d}","") )
    #end
  #end
#end</code></pre><p>You get the correct result:</p><pre><code class="language-markup">{LodgingType=commercial hotel, Host=Zaan Hotel, PointsProgram=null, City=Amsterdam, Meals=false}</code></pre><p>Pretty crazy, right?</p><h2 id="there%E2%80%99s-a-tiny-bit-more">There’s a tiny bit more</h2><p>Sufficiently artificially-stimulated readers will notice there’s still a bug. While you wouldn’t naturally<a href="#notes" rel="noreferrer">[5]</a> encounter this in a Marketo context, Java allows LinkedHashMaps to have a single <code>null</code> <em>key, </em>not just <code>null</code> <em>values!</em><a href="#notes" rel="noreferrer">[6]</a> Velocity will faithfully preserve this existing <code>null</code>, but will bug out if you try to assign <code>$key</code> to it.</p><p>Java-safe VTL thus avoids both <code>#set</code> directives:</p><pre><code class="language-velocity">#foreach( $oppty in $OpportunityList )
  #foreach( $entry in $oppty.entrySet() )
    #if( $entry.getValue().class.name.equals("java.lang.String") )
      #set( $oppty[$entry.getKey()] = $entry.getValue().replaceAll("^[\h\v]+|[\h\v]+${esc.d}","") )
    #end
  #end
#end
</code></pre><p>Ugh. If you didn’t know better, you’d think this was junior-authored code. But it’s only junior-<em>style</em> to cooperate with Velocity’s quirks.  </p><h5 id="notes">Notes</h5>
<p>[1] That is, types lacking an explicit <code>compareTo</code>. As I’ve noted on many community threads, the automatic type coercion with <code>==</code> creates hard-to-find errors and <mark>it’s better to use <code>equals()</code></mark> even if the engine gives you something <em>seemingly </em>easier.</p><p>[2] This behavior was removed in the next Velocity release (2.0) <em>after </em>Marketo’s current version (1.7) because it did more harm than good. Unfortunately, if Marketo were to upgrade, any tokens that purposely or coincidentally rely on this behavior would break! Hard to upgrade with so many userland Velocity “cooks,” most of whom — just being honest — wouldn’t even know <em>how </em>to audit their code to find such dependencies.</p><p>[3] The Velocity <mark>context is agnostic about objects &amp; datatypes</mark> provided by the hosting Java VM. This is good! It means you can work with complex data structures, custom/child classes, etc. from VTL and <code>null</code> is totally allowed. But prior to Velocity 2.0, they really, really didn’t want you to create new <code>null</code> <code>$references</code>.</p><p>[4] Wouldn’t be noticeable until you did it thousands of times, but dereferencing <code>$entry.getValue()</code> over and over is less efficient than deref’ing once and storing the result.</p><p>[5] In Marketo, neither Person field names, Opportunity field names, nor CO field names can be <code>null</code> because the app doesn’t allow it. You <em>can,</em> however, purposely <code>put()</code> a <code>null</code> key into a LinkedHashMap (even including the <code>$lead</code> object, since it’s mutable). <code>put()</code> is different from <code>#set</code>-ing a reference!</p><p>[6] N.B. a JavaScript Map also allows a single <code>null</code> key, and a single <code>undefined</code> key too.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Properly trimming Strings in Velocity takes more than trim() ]]></title>
        <description><![CDATA[ It’s almost certain that Velocity’s (that is, Java’s) trim() doesn’t do what you consider “trimming whitespace.” ]]></description>
        <link>https://blog.teknkl.com/properly-trimming-strings-in-velocity-takes-more-than-trim/</link>
        <guid isPermaLink="false">6a59a271f6c10f0001a73d98</guid>
        <category><![CDATA[ velocity ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Sat, 18 Jul 2026 14:20:55 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/07/velocity-marketo-logo-1.png" medium="image"/>
        <content:encoded><![CDATA[ <p>We sometimes see people <code>trim()</code>-ing Strings in Velocity before comparing them, like:</p><pre><code class="language-velocity">#if( $lead.Region.trim().equals("Northeast") )</code></pre><p>Without evidence that leads or apps have added extra whitespace around values, worrying about <code>trim()</code> here is likely overkill. If data is validated via picklists or reliable apps (e.g. you can easily trim Marketo form fields before submission<a href="#notes" rel="noreferrer">[1]</a>) you’re just forcing Velocity/Java to allocate memory for new Strings.</p><p>Some people <code>trim()</code> before <code>isEmpty()</code>, in case a value consists of <em>only </em>spaces:</p><pre><code class="language-velocity ">#if( $lead.LastName.trim().isEmpty() )</code></pre><p>The real flaw in both cases isn’t the <em>concept </em>of trimming, it’s that <mark>the <em>types </em>of </mark><a href="https://docs.oracle.com/javase/8/docs/api/java/lang/String.html?ref=blog.teknkl.com#trim--" rel="noreferrer"><mark>whitespace trimmed by <code>trim()</code></mark></a><mark> are very limited</mark> and don’t represent the real-world whitespace that janks up databases. For example, <a href="https://en.wikipedia.org/wiki/Non-breaking_space?ref=blog.teknkl.com" rel="noreferrer">U+00A0 NBSP</a> isn’t covered by <code>trim()</code> since it only removes ASCII whitespace! In my experience, NBSP is a bigger wtf-how-did-that-get-in-there offender<a href="#notes" rel="noreferrer">[2]</a> than basic spacebar, so you want to catch it.</p><p>So rather than <code>trim()</code>,  use the <a href="https://docs.oracle.com/javase/8/docs/api/java/util/regex/Pattern.html?ref=blog.teknkl.com#:~:text=predefined%20character%20classes" rel="noreferrer"><code>\h</code> and <code>\v</code></a> regex classes for horizontal and vertical spaces:</p><pre><code class="language-velocity">#set( $trimmedLastName = $lead.LastName.replaceAll("^[\h\v]+|[\h\v]+${esc.d}","") )
</code></pre><p>That’ll remove most everything commonly called “whitespace”, from the typical CR, LF, SP to NBSP to <a href="https://en.wikipedia.org/wiki/Newline?ref=blog.teknkl.com#Unicode" rel="noreferrer">PS and LS</a>, too.</p><h2 id="trim-every-string-upfront">Trim every String upfront</h2><p>You <em>do not </em>want to have to remember to trim wherever you reference a field. Instead, trim all Strings on <code>$lead</code> before your other code: </p><pre><code class="language-velocity">#foreach( $entry in $lead.entrySet() )
#set( $key = $entry.getKey() )
#set( $val = $display.alt($entry.getValue()) )
#if( $entry.getValue().class.name.equals("java.lang.String") )
#set( $lead[$key] = $entry.getValue().replaceAll("^[\h\v]+|[\h\v]+${esc.d}","") )
#end
#end</code></pre><p>The same can be done with <code>OpportunityList</code> or <code>AnyCustomObject_cList</code> — <code>#foreach</code> over all the records and for each record, <code>#foreach</code> over its entries as above.<a href="#notes" rel="noreferrer">[3]</a></p><hr>
<h5 id="notes">Notes</h5>
<p>[1] Like so:</p><pre><code class="language-javascript">MktoForms2.whenReady(function(readyForm){
    readyForm.onSubmit(function(submittingForm){
        const values = submittingForm.getValues();
        for( const [k,v] of Object.entries(values) ) values[k] = v.trim();        
        submittingForm.setValues(values);
    });
});
            </code></pre><p>N.B. <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/trim?ref=blog.teknkl.com#return_value" rel="noreferrer">JS <code>trim()</code> is more aggressive</a> and real-world-aware than Java <code>trim()</code>, so you don’t need a custom regex. </p><p>[2] Not so mysterious if your “data normalization” preprocessor was too forgiving... like Java <code>trim()</code>!</p><p>[3] The <code>class.name</code> condition is because <mark>some datatypes, like Integer, are preserved for custom object fields</mark>. Similarly, <code>$display.alt()</code> is because custom object fields can be <code>null</code>. </p><p>In contrast, <mark>every field on <code>$lead</code> is a non-<code>null</code> String</mark> in the Velocity context.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Show Known Visitor HTML on other open tabs after a form submit ]]></title>
        <description><![CDATA[ A fun use for BroadcastChannel, a browser feature typically used by web apps (not mere LPs). ]]></description>
        <link>https://blog.teknkl.com/show-known-visitor-html-on-other-open-tabs-after-a-form-submit/</link>
        <guid isPermaLink="false">6a18ab08586605000118d46a</guid>
        <category><![CDATA[ BroadcastChannel ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Fri, 10 Jul 2026 16:21:59 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/05/formsjs.png" medium="image"/>
        <content:encoded><![CDATA[ <p><em><strong>“We do these things not because they are easy, but because we have insomnia.” — John F. Kennedy </strong>(paraphrased)</em></p><p>Known Visitor HTML lets you <em>un-gate </em>assets if the viewing lead is known, so they just see a download button. But as a completist, I’m irked by the fact that after a form submit, <mark>forms on other open tabs</mark> still show until the lead deliberately refreshes. Those forms feel like sitting ducks for conflicting form data and unnecessary friction.</p><p>If I’ve piqued your irk-terest, it remarkably easy to solve this using the <a href="https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel?ref=blog.teknkl.com" rel="noreferrer">BroadcastChannel API</a> in all modern browsers. </p><h2 id="what%E2%80%99s-a-broadcastchannel">What’s a BroadcastChannel?</h2><p>BroadcastChannel offers <mark>guaranteed-secure, cross-window, same-origin messaging</mark>. You don’t need to worry about cross-origin security because the browser simply won’t allow cross-origin <em>anything </em>over a BroadcastChannel.</p><p>(In contrast, the <a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage?ref=blog.teknkl.com" rel="noreferrer">postMessage API</a> <em>can be secure </em>if both sides enforce origin verification, but an inattentive dev can introduce leaks. postMessage lets you communicate cross-origin, so it’s more powerful overall, but we don’t need it today.)</p><p>Check the recording below to see the effect we’re going for. On the left is Page <em>L</em> with Form <em>123</em>; on the right is Page <em>R</em> also with Form <em>123</em>. When you submit the form on Page <em>L</em>:</p><p>(1) the PDF set as the Thank You URL opens </p><p><em>— and simultaneously, in the background — </em></p><p>(2) the form on Page <em>R</em> is replaced by KV HTML with just a button; should the person switch to Page <em>R</em> later, they can jump right to the PDF</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/07/broadcast01.webp" class="kg-image" alt="" loading="lazy" width="1954" height="1124"></figure>
<h2 id="get-the-code">Get the code</h2><p>Thanks to BroadcastChannel’s guaranteed security, the code is remarkably short:</p><pre><code class="language-javascript">/**
 * BroadcastChannel to send success data to other tabs and switch to KV HTML
 * @author Sanford Whiteman
 * @version v1.0.0 2026-07-08
 * @license Hippocratic 3.0: This license must appear with all reproductions of this software.
 *
 */
const successChannel = new BroadcastChannel("form.success");

MktoForms2.whenReady(function(readyForm){
   readyForm.onSuccess(function(submittedValues,thankYouHref){
      successChannel.postMessage(submittedValues);
   });
});

successChannel.addEventListener("message",function(e){
   const thisForm = MktoForms2.getForm(e.data.formid);
   
   if( thisForm ) {
      const formEl = thisForm.getFormElem()[0];
      
      // already showing KV HTML?
      if( formEl.querySelector(".mktoTemplateBox") ) return;
            
      // first non-null field from broadcast, current, random
      window.mktoPreFillFields = window.mktoPreFillFields ?? {};
      const requiredKVFields = ["FirstName", "LastName"];
      requiredKVFields.forEach( (field) =&gt; window.mktoPreFillFields[field] = e.data[field] ?? window.mktoPreFillFields[field] ?? crypto.randomUUID() );
      
      MktoForms2.allForms().splice(MktoForms2.allForms().indexOf(thisForm), 1);      
      formEl.innerHTML = "";      
      MktoForms2.loadForm(`//${e.data.munchkinId}.mktoweb.com`, e.data.munchkinId, e.data.formid);
   }
});</code></pre><h2 id="code-anatomy-kv-html%E2%80%99s-quirked-up-rules">Code Anatomy: KV HTML’s quirked-up rules</h2><p>As I’ve noted before, Known Visitor HTML has an undocumented prerequisite: <mark>it only works if the visitor has a non-empty <em>First Name </em>and <em>Last Name</em></mark>.</p><p>This is fairly logical given that you can include tokens for those fields — but <em>only </em>those fields — in the KV HTML.<a href="#notes" rel="noreferrer">[1]</a> (And yes, the tokens work <mark>even when the form embed is used on non-Marketo pages.</mark>) </p><p>You don’t <em>have to </em>include those fields in KV HTML, though: you can just show a button or link. Yet even if you don’t output <em>First </em>or <em>Last </em>on the minimal KV HTML form, the prereq still applies! Marketo checks for known visitor data in two places, in this order: </p><p>(1) the global <code>mktoPreFillFields</code> object, present on Marketo LPs with native Pre-Fill enabled</p><p>(2) the <code>getKnownLead</code> JSONP endpoint, used by the form embed on non-Marketo LPs and by Progressive Profiling (on any LP)</p><p>Since <code>mktoPreFillFields</code> is checked first, the code ensures a usable <code>mktoPreFillFields</code> is always present. It prefers the <code>FirstName</code> and <code>LastName</code> from the form that triggered the BroadcastChannel message; if those weren’t posted, it’ll fall back to an existing <code>mktoPreFillFields</code>; and finally, it’ll generate random values so KV HTML kicks in.</p><p>Therefore, <mark>if your triggering form doesn’t have <em>First Name </em>and <em>Last Name </em>on it and you’re using a non-Marketo LP</mark>, you shouldn’t use the corresponding tokens in the KV HTML because you don’t want random values shown. Just have a link or button, like I did in the recorded demo.</p><h2 id="it-doesn%E2%80%99t-even-require-cookies">It doesn’t even require cookies</h2><p>An incidental frill here is the code works <em>even if cookies are disabled. </em>KV HTML usually requires either an associated <code>mkto_trk</code> cookie or a fresh <code>mkt_tok</code> param. Since the BroadcastChannel is sending data between currently open windows, there’s no need for persistence (<code>message</code> events are ephemeral, never cached/retried.)</p><h5 id="notes">Notes</h5>
<p>[1] <em>First Name </em>and <em>Last Name </em>are also<em>, </em>to put it mildly, good to know. Given the chance to have a lead provide those values, you <em>usually </em>want to take it — but not always! Email-only records can be fine until they reach a high-value form that requires more fields. If your KV HTML-enabled form had only <em>Email Address </em>(plus some hidden attribution fields) on it, why show that “full” form again, right? </p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Creating DNS CAA records for Marketo-hosted domains ]]></title>
        <description><![CDATA[ If you can make IT happy with minimal impact, do it. (But stay skeptical of claimed “security issues” in any MAP, as they’re usually false alarms.) ]]></description>
        <link>https://blog.teknkl.com/creating-dns-caa-records-for-marketo-hosted-domains/</link>
        <guid isPermaLink="false">6a337d1ffb9108000116e593</guid>
        <category><![CDATA[ DNS ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Mon, 06 Jul 2026 00:06:37 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/06/dns.png" medium="image"/>
        <content:encoded><![CDATA[ <p>That email from IT we all dread: <strong><em>Urgent: Remediation Required </em></strong>with a list of supposed Marketo config issues found during an external audit.</p><p>Typically, the issues’ll range from factually false<a href="#notes" rel="noreferrer">[1]</a> to meaningless<a href="#notes" rel="noreferrer">[2]</a> to head-shakingly unactionable<a href="#notes" rel="noreferrer">[3]</a>, but there’s sometimes a valid point in there. (Especially if it’s tagged <em>Low Priority </em>so neither side has to get defensive.)</p><p>A legit point that came up recently is <mark>missing DNS CAA records for Marketo LP and Branding (click tracking) domains</mark>. The Marketo instances still passed the audit because it was tagged P3 (Low), but there’s good reason to try to make IT happy.</p><h2 id="what-caa-records-do">What CAA records do</h2><p><mark>CAA records list the specific Certificate Authorities allowed to issue SSL certs for a host</mark>.</p><p>For example, if your company is on AWS with Amazon-issued ACM certs, IT can publish a CAA record that says <em>only Amazon’s CA </em>is allowed to issue certs for <code>example.com</code> and all its subdomains. Any <em>other </em>CA must reject a cert request if they are to stay in good standing with the CA/Browser Forum.<a href="#notes" rel="noreferrer">[4]</a> </p><p><em>In theory, </em>this helps discourage “Rogue IT” (an epithet sometimes unfairly hurled at Marketo). That is, non-IT users enabling SaaS apps without going through proper security channels. In reality, it doesn’t impact internal/inadvertent rogues much<a href="#notes" rel="noreferrer">[5]</a>, but there are rare-but-not-impossible situations where external malicious actors might be thwarted.<a href="#notes" rel="noreferrer">[6]</a></p><p>CAA records have <a href="https://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml?ref=blog.teknkl.com#:~:text=Certification%20Authority%20Restriction" rel="noreferrer">their own RRType</a> (CAA/257), unlike records like SPF<a href="#notes" rel="noreferrer">[7]</a>, DKIM, and DMARC which are actually repurposed TXT records. An example allowing the commercial CA GlobalSign:</p><pre><code class="language-markup">pages.example.com. 3600 IN    CAA    0 issue "globalsign.com"</code></pre><p>If you have more than a passing familiarity with DNS CNAMEs, you instantly saw the problem:</p><h2 id="a-label-used-in-a-cname-cannot-be-used-in-any-other-dns-record-types">A label used in a CNAME <em>cannot be used in any other DNS record types</em></h2><p>To set up a Marketo LP domain or domain alias, you use a CNAME record:</p><pre><code class="language-markup ">pages.example.com. 3600 IN    CNAME    123-ABC-456.mktoweb.com</code></pre><p>The record above states the name (left-hand side) <code>pages.example.com.</code> is not canonical. It’s a mere alias for <code>123-ABC-456.mktoweb.com</code>, which is the canonical name. (Please, I’m begging you, never forget this: <em>the right-hand side is the canonical name!</em><a href="#notes" rel="noreferrer">[8]</a>)</p><p>CNAMEs are convenient (the internet wouldn’t work without them) but have a natural side effect: <mark>once you say a name is an alias, it can’t have any other DNS records besides the CNAME</mark>.</p><p>Think about aliases or nicknames in real life. Patrick goes by “Buddy.” Buddy and Patrick can’t have different addresses, ages, and so on, right? Those are ultimately characteristics of Patrick — Buddy, Bud, and B-Man merely point to Patrick’s data. The DNS CNAME restriction is much the same.</p><p>So there’s a core conflict: if <code>pages.example.com</code> is an alias for another hostname, <code>pages.example.com</code> can’t <em>also </em>have its own CAA record.</p><h2 id="caa-records-do-understand-cnames-don%E2%80%99t-get-your-hopes-up-though">CAA records <em>do </em>understand CNAMEs (don’t get your hopes up, though)</h2><p>Now, the DNS CAA standard <em>does </em>understand the CNAME issue... to a degree. Take this chain of CNAMEs typical of Marketo LP domains: </p><pre><code class="language-markup ">&gt; dig pages.example.com
;; flags: qr rd ra; QUERY: 1, ANSWER: 7, AUTHORITY: 0, ADDITIONAL: 1

;; QUESTION SECTION:
;pages.example.com.    IN      A

;; ANSWER SECTION:
pages.example.com.       300     IN      CNAME   exampleglobal.mktoweb.com.
exampleglobal.mktoweb.com. 300   IN      CNAME   sj01.mktossl.com.
sj01.mktossl.com. 300 IN      A       104.16.93.80
sj01.mktossl.com. 300 IN      A       104.16.92.80
sj01.mktossl.com. 300 IN      A       104.16.94.80
sj01.mktossl.com. 300 IN      A       104.16.95.80
sj01.mktossl.com. 300 IN      A       104.16.96.80</code></pre><p>In this case, <em>if </em><code>sj01.mktossl.com</code> had a CAA record, that would be consulted when generating the cert for <code>pages.example.com</code>. But those <code>*.mktossl.com</code> hosts don’t have CAA records, either:</p><pre><code class="language-markup">&gt; dig sj01.mktossl.com CAA
;; flags: qr rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 1, ADDITIONAL: 1

;; QUESTION SECTION:
;pages.example.com.    IN      CAA

;; AUTHORITY SECTION:
mktossl.com.            30      IN      SOA     bart.ns.cloudflare.com. dns.cloudflare.com. 2408205987 10000 2400 604800 1800</code></pre><p>For those not accustomed to <code>dig</code> output, that means “The CAA query didn’t throw any <em>errors,</em> but also didn’t have any answers. Also, here’s some high-level info about the domain.” (Long way of saying there’s no CAA record.)</p><p>So there’s our task: <mark>if IT wants <code>pages.example.com</code> to have CAA protection, we have to try our best despite known technical barriers.</mark> There are 3 options; the 3rd one is best but might not be feasible in your environment.  </p><h2 id="option-1-append-to-the-caa-records-for-examplecom">Option 1: Append to the CAA records for <code>example.com</code></h2><p>In addition to chasing CNAMEs — which doesn’t help here since <code>sj01.mktossl.com</code> doesn’t have a CAA — CAA lookup logic also checks <em>all parent private domain(s) </em>of a host, e.g. for <code>pages.example.com</code> it looks for a CAA at <code>example.com</code>, too.</p><p>If IT is insisting on CAA records, they’ve already covered the parent domain like so:</p><pre><code class="language-markup">&gt; dig example.com caa
;; flags: qr rd ra; QUERY: 1, ANSWER: 3, AUTHORITY: 0, ADDITIONAL: 1

;; QUESTION SECTION:
;example.com.    IN      CAA

;; ANSWER SECTION:
example.com.         300     IN      CAA     0 issue "amazonaws.com"
example.com.         300     IN      CAA     0 issue "digicert.com"
example.com.         300     IN      CAA     0 issue "globalsign.com"</code></pre><p>So you could ask them to add the Marketo issuers as well:</p><pre><code class="language-markup">example.com. 3600 IN     CAA     0 issue "pki.goog; cansignhttpexchanges=yes"
example.com. 3600 IN     CAA     0 issue "letsencrypt.org"</code></pre><p>Worth a try, but they’d be justified in pushing back. Problem with blindly adding new CA issuers at the top-level <code>example.com</code> is <mark>those issuers are inherited by any subdomain that doesn’t have its own CAA.</mark> Now all of ’em can get certs from Let’s Encrypt or Google in addition to Amazon, DigiCert, and GlobalSign. If you keep domain-wide adding CAs like that, CAA’s security promises — already somewhat overblown — become de facto meaningless. </p><p>Again, not to say you shouldn’t ask, but there’s a good reason to say no.</p><h2 id="option-2-create-a-new-subdomain-with-caa-records-and-put-marketo-domains-one-level-deeper">Option 2: Create a new subdomain with CAA record(s) and put Marketo domains one level deeper</h2><p>One way to keep IT happy is to create a new subdomain like <code>m.example.com</code> or <code>mkto.example.com</code> and give <em>that </em>domain the required CAA records:</p><pre><code class="language-markup">m.example.com. 3600 IN     CAA     0 issue "pki.goog; cansignhttpexchanges=yes"
m.example.com. 3600 IN     CAA     0 issue "letsencrypt.org"</code></pre><p>This way the global CAAs for <code>example.com</code> need not be overly permissive.</p><p>Your LP domain can then be <code>pages.m.example.com</code> and branding domain <code>go.m.example.com</code>. Those get the CNAME records:</p><pre><code class="language-markup ">pages.m.example.com. 3600 IN    CNAME    123-ABC-456.mktoweb.com
go.m.example.com. 3600 IN    CNAME    mkto-7890.com</code></pre><p>I’ve become surprisingly sanguine about this setup. In fact, for a client where we’re rolling out 2 new Marketo instances, I was downright <em>enthusiastic </em>about it! Each instance serves a different side of the business. Putting retail under <code>r.example.com</code> and wholesale under <code>w.example.com</code> <mark>allows both to use the label <code>go</code>,</mark> i.e. <code>go.r.example.com</code> and <code>go.w.example.com</code>.</p><p>That said, I don’t mean to downplay the change. While it has no technical impact — don’t worry, cookies are shared between sub-subdomains and the main domain!  — some might bridle at the <em>cosmetic </em>impact. I get that, but again, it’s principally about staying in good terms with IT so they’ll be responsive to other requests.😏</p><h2 id="option-3-non-standard-alias-type-records">Option 3: Non-standard ALIAS type records</h2><p>ALIAS records are the best of both worlds, but your DNS infrastructure might not support ’em. (In the screenshots below, I’m showing perhaps my favorite app of all time, the DNS server <a href="https://simpledns.plus/docs/alias-records?ref=blog.teknkl.com" rel="noreferrer">Simple DNS Plus</a><em>. </em>Other servers/services that support ALIAS are <a href="https://dnsimple.com/?ref=blog.teknkl.com" rel="noreferrer">DNSimple</a> and <a href="https://doc.powerdns.com/authoritative/guides/alias.html?ref=blog.teknkl.com" rel="noreferrer">PowerDNS</a>.<a href="#notes" rel="noreferrer">[9]</a>)</p><p>ALIAS isn’t an actual DNS RRType; rather, it’s a <em>concept </em>implemented similarly by vendors. Confusingly, it’s usually written in all-caps like a standard. ALIAS addresses the shortcomings of CNAMEs via what you might call “stealth CNAMEs” or “private CNAMEs” or “server-side CNAMEs”<a href="#notes" rel="noreferrer">[10]</a>.</p><p>To the public, ALIAS records appear to be A records:</p><pre><code class="language-markup">&gt; dig pages.marketo-caa-demo.com
;; flags: qr rd ra; QUERY: 1, ANSWER: 5, AUTHORITY: 0, ADDITIONAL: 1

;; QUESTION SECTION:
;pages.marketo-caa-demo.com.    IN      A

;; ANSWER SECTION:
pages.marketo-caa-demo.com. 288 IN      A       104.16.93.80
pages.marketo-caa-demo.com. 288 IN      A       104.16.92.80
pages.marketo-caa-demo.com. 288 IN      A       104.16.94.80
pages.marketo-caa-demo.com. 288 IN      A       104.16.95.80
pages.marketo-caa-demo.com. 288 IN      A       104.16.96.80</code></pre><p>Within DNS control panels, ALIASes are set up much like CNAMEs — a hostname is pointed to its <em>canonical name</em>.</p><p>The difference is:</p><ul><li><mark>with standard CNAME resolution, the CNAME is revealed to both the client and the resolver</mark>, i.e. the resolver chases each <code>CNAME</code> value, and the client can see those intermediate <code>CNAME</code> records in the response alongside final answers</li><li>with ALIAS resolution, <mark><em>only </em>the authoritative nameserver for <code>example.com</code> knows there was any kind of aliasing</mark>; neither the recursor nor the client has any idea there was another lookup</li></ul><h3 id="an-important-cnamealias-quirk-with-marketo-in-particular">An important CNAME/ALIAS quirk with Marketo in particular</h3><p>If you’re lucky enough to have ALIAS support, <mark>you still need to create a temporary CNAME record</mark> in order for LP/branding domains to validate in Marketo. Then after validation, delete it and replace it with an ALIAS.</p><p>In SimpleDNS Plus, you’d first create the CNAME:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/07/image.png" class="kg-image" alt="" loading="lazy" width="641" height="482" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2026/07/image.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/07/image.png 641w"></figure><p>Wait for it to validate in Marketo Admin, then replace it with the ALIAS:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/06/image-12.png" class="kg-image" alt="" loading="lazy" width="636" height="622" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2026/06/image-12.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/06/image-12.png 636w"></figure><p>Now that you don’t have a CNAME conflict, create the CAA records:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/06/image-11.png" class="kg-image" alt="" loading="lazy" width="639" height="612" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2026/06/image-11.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/06/image-11.png 639w"></figure><h5 id="notes">Notes</h5>
<p>[1] Like “High priority: <code>pages.example.com</code> doesn’t support SSL” when it clearly does, they just didn’t double-check their automated audit.</p><p>[2] Like “Medium priority: port 443 is open on <code>click.example.com</code>” as if that’s not intentional and required.</p><p>[3] Favorite recent one of these is “Medium priority: <code>pages.example.com</code> is not in the HSTS Preload list.” (Only zone apexes like <code>example.com</code> are allowed in <a href="https://hstspreload.org/?ref=blog.teknkl.com" rel="noreferrer">HSTS Preload</a> so it’s entirely on the IT team to implement this, not the Marketo admin.)</p><p>[4] The current <a href="https://cabforum.org/working-groups/server/baseline-requirements/documents/CA-Browser-Forum-TLS-BR-2.2.8.pdf?ref=blog.teknkl.com" rel="noreferrer">baseline requirements</a> for CAs require CAA enforcement:</p><blockquote>4.2.2.1 CAA record processing <br>As part of the Certificate issuance process, the CA MUST retrieve and process CAA records in accordance with RFC 8659 for each <code>dNSName</code> in the <code>subjectAltName</code> extension that does not contain an Onion Domain Name.</blockquote><p>Note if a CA gets hacked, just removing corresponding CAA records doesn’t invalidate already-issued certs (and since it’s <em>up to the CA operator </em>to enforce CAA, if they get compromised the CAA record has no meaning). But the next thing that happens after such a hack — hopefully very soon after! — is the CA root cert <a href="https://www.mozilla.org/en-US/about/governance/policies/security-group/certs/policy/?ref=blog.teknkl.com" rel="noreferrer">gets revoked</a> in browsers so its issued certs become invalid.</p><p>[5] IT balances the <em>threat </em>of Rogue IT with the <em>promise </em>of Rogue IT, that is, that it gives them less to do.😉 If budget is approved and legal is a go, they’ll typically greenlight DNS entries for a SaaS — even a pretty niche one in my experience — if the vendor seems to know what they’re doing. So rather than a hardcore security measure, the DNS CAA entry becomes a common onboarding step.</p><p>And once the zone apex’s CAA allows certs from, say, Amazon or Let’s Encrypt, hundreds of <em>other </em>SaaS apps use those popular CAs. Rogues are only stopped if an app uses a CA that isn’t <em>already </em>allow-listed. Account-specific CAA records would prevent this, e.g. this one only authorizes Let’s Encrypt account <code>123456789</code>, which belongs to a single SaaS provider:</p><pre><code class="language-markup">example.com 300 IN    CAA     0 issue "letsencrypt.org;validationmethods=http-01;accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/123456789"</code></pre><p>But if a provider allocates/rotates Let’s Encrypt accounts a lot, it’s impractical to embed an account in the CAA record. If you survey real-world CAA records, they’re likely to allow <em>any </em>account at a given CA:</p><pre><code class="language-markup">example.com 300 IN    CAA     0 issue "letsencrypt.org"</code></pre><p>[6] A case I find <em>fairly </em>compelling is a defunct/orphaned SaaS app where a branded subdomain, say <code>socially.example.com</code>, is still pointing to the app’s IP because no one knew to remove the DNS record. Then a hacker finds a vulnerability that lets accounts use any domain pointed to the platform. (Or maybe the IP address was released and is used by a new platform entirely.) </p><p>Without a CAA record, the hacker could use any CA that supports HTTP authentication (i.e. when you upload a file to <code>http://socially.example.com/testfile</code> to prove you control the domain), then serve up a phishing site at <code>https://socially.example.com</code>. With CAA, they’d have to use the same CA used by the now-hacked platform, a smaller attack surface.</p><p>[7] SPF used to have its own RRType (SPF/99) but rolling it out proved difficult and it’s <a href="https://datatracker.ietf.org/doc/html/rfc7208?ref=blog.teknkl.com#section-14.1" rel="noreferrer">no longer used</a>.</p><p>[8] More correctly, the right-hand side is the <em>possibly </em>canonical name in the grand lookup scheme. The right-hand side may itself turn out to be an alias once you look it up! Indeed, in Marketo setups, <code>&lt;munchkin subdomain&gt;.mktoweb.com</code> isn’t canonical either: it’s an alias for the A record <code>&lt;subdomain&gt;.mktossl.com</code> and <em>the latter </em>is the canonical name.</p><p>[9] To my knowledge, providers like Amazon Route 53 and Google Cloud DNS enable their ALIAS-like feature only if the destination is hosted in the same cloud account. E.g., with Route 53 you can ALIAS <code>whatever.example.com</code> <mark>if the origin is behind one of your own CloudFront distributions</mark>. But that wouldn’t work for Marketo LP domains, which are behind Adobe’s Cloudflare proxies. </p><p>[10] The <mark>“server” in “server-side” meaning an <em>authoritative </em>DNS server</mark>. Standard CNAMEs are resolved by <em>recursive </em>DNS servers, so they’re also server-side in the broad sense.</p><p>Security tidbit: ALIAS means authoritative nameservers perform DNS lookups, albeit secretly. Normally, an authoritative NS doesn’t need outbound network access beyond zone transfers with its peers (other NSes for <code>example.com</code>). But if it’s gonna turn <code>pages.example.com</code> into a bunch of A records, that requires either a local recursor or a connection to an upstream recursor. Not a problem in modern times, but it’s looser than how paranoid sysadmins set things up in the past.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Adding hidden fields (e.g. UTMs) to Marketo Dynamic Chat dialogues ]]></title>
        <description><![CDATA[ They’ll tell you it can’t be done. But they didn’t count on the power of having no social life. ]]></description>
        <link>https://blog.teknkl.com/adding-hidden-fields-e-g-utms-to-marketo-dynamic-chat-dialogues/</link>
        <guid isPermaLink="false">6a2cf1b2577fd20001f69923</guid>
        <category><![CDATA[ dynamic chat ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Sun, 14 Jun 2026 22:53:51 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/06/marketo_quirks-2.png" medium="image"/>
        <content:encoded><![CDATA[ <p>To set the record straight, I <em>do </em>have a social life. It just comes in fits and starts. Even have tickets for an <a href="https://kydlabs.com/e/EV5f4f1ea2-f7dd-484f-8b37-1a52c774f31e?ref=blog.teknkl.com" rel="noreferrer">album release party</a>, uh, 6 weeks from now. 🥲</p><p>Marketo’s Dynamic Chat is an able alternative to third-party apps (indeed, it’s technically a separate app that syncs bidirectionally with Marketo Engage). But one thing it lacks is <mark>hidden field support</mark> which is so key for attribution.</p><p>Many of our clients don’t notice this gap because they use API-based reporting to see chats in the context of full journeys.<a href="#notes" rel="noreferrer">[1]</a> But for those without such a solution, the fact you <mark>can’t even pop the current page’s UTM params</mark> (let alone data in cookies/local storage) into fields is frustrating.</p><h2 id="quick-look-before-i-slam-the-hood">Quick look before I slam the hood</h2><p>Each Dynamic Chat dialogue is a sequence of mini HTML <code>&lt;form&gt;</code> elements whose data is routed over a persistent websocket, rather than a traditional HTTP POST. I really like this implementation because it keeps HTML semantics — you can see the <code>&lt;form&gt;</code>, <code>&lt;input&gt;</code>, and <code>&lt;button&gt;</code> — but gives way better performance than if it made a separate post for each step.<a href="#notes" rel="noreferrer">[2]</a></p><p>Also, dialogues use a combination of Light DOM and Shadow DOM trees, so show/hide logic needs to be aware of where to inject styles.    </p><p>So my main design goals were:</p><ul><li>detect whether each newly injected input (<em>Info capture </em>step in Dynamic Chat-ese) matches one of our hidden fields </li><li>if so, (1) hide it using CSS, (2) fill its value from a source object, and (3) submit its accompanying form automatically</li></ul><p>That’s pretty much it at a high level. But the code to do it right proved pretty tricky.</p><h2 id="step-1-set-up-your-dialogue-with-hidden-fields">Step 1: Set up your Dialogue with hidden fields</h2><p>Adding to-be-hidden fields in <em>Dynamic Chat » Dialogues </em>is simple. They’re like any other <em>Info capture </em>fields and you should use the same label for the <em>Chatbot Message </em>and the <em>Placeholder. </em>You can actually choose whatever label you want, just pass that label to <code>addHiddenChatFields()</code> as described later:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/06/image-6.png" class="kg-image" alt="" loading="lazy" width="1578" height="1263" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2026/06/image-6.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2026/06/image-6.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/06/image-6.png 1578w" sizes="(min-width: 720px) 720px"></figure><p>Notice in this example the hidden fields are <em>after </em>the last visible field, <em>Email. </em>So once the person submits their <em>Email </em>(but not before), the UTMs are picked up from the source. This is <em>probably </em>the order you want in your environment, but maybe not.</p><h2 id="on-field-order-and-persistence">On field order and persistence</h2><p>You might not realize <mark>Dynamic Chats remember <em>Info capture </em>answers within the same session, a form of progressive profiling</mark>. A DC session <em>persists across pageviews, </em>with partial data, until you deliberately clear the session or the lead finishes the playbook.</p><p>Depending on how you’re getting the values to pass to <code>addHiddenChatFields()</code> you want to either use the <em>latest available </em>values as of chat completion (i.e. conversion), or retain the <em>original values </em>as of the start of the chat.</p><ul><li>if you’re using a JS persistence library to store attribution history in cookies/local storage<a href="#notes" rel="noreferrer">[3]</a> and getting values from there, put all hidden fields <strong>at the end</strong></li><li>if you don’t have JS persistence but only want to copy UTMs <em>from the conversion page itself, </em>also put them all <strong>at the end </strong></li><li>if you don’t have JS persistence but want to leverage Dynamic Chat’s own session-level tracking<a href="#notes" rel="noreferrer">[4]</a>, put “original”  type fields <strong>at the start </strong>of the stream and “most recent” type fields <strong>at the end</strong></li></ul><p>Overall, <mark>where field values come from</mark> is your decision. The code is for dropping ’em in the chat, whatever their origin.</p><h2 id="step-2-get-the-code">Step 2: Get the code</h2><p>Here’s <code>addHiddenChatFields()</code>, a minor masterpiece of monkey patching if I do say so:</p><pre><code class="language-javascript ">  /**
   * Enable hidden `Info Capture` fields in Marketo Dynamic Chat  
   *
   * @author Sanford Whiteman, TEKNKL
   * @version v1.0.0 2026-05-12
   * @license Hippocratic 3.0: This license must appear with all reproductions of this software.
   * @param { Map&lt;string,string&gt;|Object.&lt;string,string&gt; } sourceObject - supports Map, Map-like interfaces (e.g. URLSearchParams), and Objects   
   * @param { string[] } fieldList - list of keys to grab from sourceMap  
   * @param { string } [defaultValue=NULL] - used when value is falsy
   * @param { boolean } [unhide=false] - temporarily reveal hidden fields for debugging
   * @returns {undefined}
   */  
  function addHiddenChatFields(sourceObject, fieldList, defaultValue = "NULL", unhide = false){
    // ducktype Map-like `get` or make Map
    const sourceMap = typeof sourceObject.get == "function" ? sourceObject : new Map(Object.entries(sourceObject)); 

    const hiderSheet = new CSSStyleSheet();
    for( const field of fieldList ) {
      hiderSheet.insertRule(`div.hb_chat_server:has(input[placeholder="${ CSS.escape(field) }"]) { display: none; }`);
    }

    new MutationObserver((mutationRecords, lightObserver) =&gt; {
      iterateLightRecords: 
      for( const record of mutationRecords ) {
        for( const node of record.addedNodes ){
          if( node.id === "hb_chatbot-root" ) {
            lightObserver.disconnect();

            node.shadowRoot.adoptedStyleSheets = [hiderSheet]
            new MutationObserver((mutationRecords, darkObserver) =&gt; {
              iterateDarkRecords: 
              for( const record of mutationRecords) {
                for( const node of record.addedNodes ) {
                  if( node.nodeType == Node.ELEMENT_NODE ) {
                    for( const inspectable of [node, ...node.querySelectorAll("INPUT")] ) {
                      if( inspectable.nodeName === "INPUT" &amp;&amp; fieldList.includes(inspectable.placeholder) ) {
                        const { groups: { uuid } } = inspectable.id.match(/hb_(?&lt;uuid&gt;.+)_/);
                        
                        if( inspectable.isConnected ) {
                          inspectable.value = sourceObject.get(inspectable.placeholder) || defaultValue;
                          inspectable.form.querySelector("BUTTON").click();
                        }
                        hiderSheet.insertRule(`div:is(.hb_chat_client, .hb_chat_server)[id^="hb_${ CSS.escape(uuid) }"] { ${ unhide ? `outline: 2px solid gold;` : `display: none;` } }`);
                      }
                    }
                  }
                }
              }
            }).observe(node.shadowRoot, { childList: true,  subtree: true });

            break iterateLightRecords;
          }
        }
      }
    }).observe(document.body, { childList: true });
  }</code></pre><p>This code can be before or after the Dynamic Chat <strong><em>loader.js, </em></strong>doesn’t matter.<strong> </strong></p><h2 id="examples">Examples</h2><p><code>addHiddenChatFields()</code> is deliberately flexible: it doesn’t just support query params, it can use <em>any </em>string values from a Map-like object or simple Object! Of course <mark>a URLSearchParams instance is one such Map-like</mark>, so pass query params like so:</p><pre><code class="language-javascript">const fields = ["utm_medium", "utm_source"];
const source = new URLSearchParams(document.location.search);

addHiddenChatFields(source,fields);</code></pre><p>To change the (global) default value when any field is missing or empty:</p><pre><code class="language-javascript">const fields = ["utm_medium", "utm_source"];
const source = new URLSearchParams(document.location.search);

addHiddenChatFields(source,fields,"N/A");</code></pre><p>If you want different defaults per field, set them in the source before adding: </p><pre><code class="language-javascript">const fields = ["utm_medium", "utm_source"];
const source = new URLSearchParams(document.location.search);
const defaults = new URLSearchParams("utm_source=Direct&amp;utm_medium=Not Provided");
for( const [name,value] of defaults ) {
    source.has(name) || source.set(name,value)
}

addHiddenChatFields(source,fields);</code></pre><p>If you have consolidated data from cookies/local storage + current URL in an Object (<mark>this is what those persistence libraries are for!</mark>), use that:</p><pre><code class="language-javascript">const fields = ["recent_utm_medium", "recent_utm_source", "original_utm_medium", "original_utm_source"];
const source = YourPersistedObject;

addHiddenChatFields(source,fields);</code></pre><p>By the way, with the new <a href="https://developer.mozilla.org/en-US/docs/Web/API/CookieStore?ref=blog.teknkl.com" rel="noreferrer">CookieStore API</a>, you can fetch cookies natively in modern browsers and pass them in:</p><pre><code class="language-javascript">const fields = ["stored_utm_medium", "stored_utm_source"];

Promise.all( fields.map( (name) =&gt; cookieStore.get(name) ) )
.then( (cookies) =&gt; cookies.map( (cookie) =&gt; [cookie.name, cookie.value] ) )
.then( (entries) =&gt; new Map(entries) )
.then( (source) =&gt; addHiddenChatFields(source,fields) )</code></pre><p>Finally, for debugging purposes only, set <code>unhide</code>:</p><pre><code class="language-javascript">const fields = ["utm_medium", "utm_source"];
const source = new URLSearchParams(document.location.search);

addHiddenChatFields(source,fields,"NULL",true);</code></pre><p><code>unhide</code> reveals how the sausage is made, outlining to-be-hidden fields instead of fully hiding them:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/06/image-9.png" class="kg-image" alt="" loading="lazy" width="601" height="952" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2026/06/image-9.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/06/image-9.png 601w"></figure><h5 id="notes">Notes</h5>
<p>[1] Dynamic Chat creates a Marketo activity upon completion, and the details contain the full page URL. Apps consuming the raw Activity Log can parse that URL and slipstream it into the standard Munchkin pageview/click journey.</p><p>[2] OK, with HTTP/3 and QUIC you might get competitive, but sooner or later you’d want <a href="https://websocket.org/comparisons/webtransport/?ref=blog.teknkl.com" rel="noreferrer">WebTransport</a> anyway.</p><p>[3] Honestly can’t imagine how any Marketo shop does <em>without </em>such a library, or the API-driven equivalent noted above! (Even apps like Marketo Measure or Segment don’t cover every single case.)</p><p>[4] Dynamic Chat’s sessions really aren’t up to the multitouch task, though. For example, if you put the <em>Info capture </em>for <code>original_utm_source</code> at the top, the session stores the <code>utm_source</code> from the first time someone opened the dialogue; if it’s missing, the session stores your default value, like <code>NULL</code> or <code>Direct</code>. If it was originally missing and the person visits a UTM-tagged URL without chatting, then later hits <em>another </em>UTM-tagged URL where they complete the dialogue, you aren’t able to get anything from that <em>intermediate </em>page. <mark>You can only change the value from the default once the session is reset or completed.</mark></p><p>(And yes, I realize these remarks are probably confusing — I’m steeped in this stuff every day so it’s second nature. <strong>tl;dr: </strong><em>a persistence library will always have more intelligence than Dynamic Chat sessions.</em>)</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Avoid log purge surprises with “Last [Activity] Week Ending” fields ]]></title>
        <description><![CDATA[ You’ll wish you’d done this 90 days ago. ]]></description>
        <link>https://blog.teknkl.com/avoid-log-purge-surprises-with-last-activity-week-ending-fields/</link>
        <guid isPermaLink="false">69939e4cb65dc20001b79828</guid>
        <category><![CDATA[ data retention ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Fri, 05 Jun 2026 23:53:58 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/06/marketo_quirks.png" medium="image"/>
        <content:encoded><![CDATA[ <p>By now you know Marketo’s <a href="https://experienceleaguecommunities.adobe.com/adobe-marketo-engage-knowledge-base-28/marketo-activities-data-retention-policy-245173?ref=blog.teknkl.com" rel="noreferrer">log retention policies</a> mean you can’t get a 360° view of someone’s engagement back further than 90 days unless you take specific measures in advance.</p><p>For some, those measures are <mark>exporting activities to a data warehouse every night</mark>. But even if we tech types arrogantly assume <em>everyone who’s anyone </em>can get that done, in reality it’s a minority of Marketo shops who do it, let alone do it right.<a href="#notes" rel="noreferrer">[1]</a></p><p>For the rest of us, the solution is to <mark>stamp Person fields with the last interesting data</mark>. Here’s where we buck the orthodoxy a bit. In our view, it doesn’t matter whether someone received an email on a Tuesday or a Thursday 10 months ago. It only matters, at best, whether they had activity <em>sometime that week</em>.</p><p>Skipping unnecessary granularity means far less work for your instance. Rather than batch campaigns processing huge swaths of your database every day — or, even worse, trigger campaigns — you just run weekly campaigns on Sundays, using the handy datetime filter <strong><em>[in time frame] Last Week S(un) S(at)</em></strong>.</p><p>We usually set up these 4 fields at minimum:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/02/image-1.png" class="kg-image" alt="" loading="lazy" width="813" height="596" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2026/02/image-1.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/02/image-1.png 813w" sizes="(min-width: 720px) 720px"></figure><p>And a Sunday batch campaign for each: </p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/02/image-2.png" class="kg-image" alt="" loading="lazy" width="726" height="585" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2026/02/image-2.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/02/image-2.png 726w" sizes="(min-width: 720px) 720px"></figure><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/02/last_week_ending_03_shadow.webp"><p>Case you didn’t catch it in the video, each campaign uses filters like this (these specific filters are for <em>Last Munchkin Web Activity Week Ending</em>, which combines page views<em> + </em>page clicks):</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/02/image.png" class="kg-image" alt="" loading="lazy" width="1608" height="896" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2026/02/image.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2026/02/image.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1600/2026/02/image.png 1600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/02/image.png 1608w" sizes="(min-width: 720px) 720px"></figure><h5 id="notes">Notes</h5>
<p>[1] Alluding to people not realizing you can’t just export <em>yesterday’s </em>logs because anonymous leads are constantly getting merged. Grr.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Yes, you can use Velocity in the native Preheader area, just not the way you’re used to ]]></title>
        <description><![CDATA[ Welcome to Workaround Town. Ain’t no documentation-believin’ newbies round here. ]]></description>
        <link>https://blog.teknkl.com/yes-you-can-use-velocity-in-the-native-preheader-area-just-not-the-way-youre-used-to/</link>
        <guid isPermaLink="false">6a0a95a7bde9300001294554</guid>
        <category><![CDATA[ velocity ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Fri, 22 May 2026 02:57:04 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/06/token_tricks_v2.png" medium="image"/>
        <content:encoded><![CDATA[ <p>You’ve got a simple Velocity token <code>{{my.credits remaining}}</code> to calculate a subscriber’s current credits based on a couple of fields:</p><pre><code class="language-velocity">#set( $creditsRemaining = $math.sub( $lead.monthlyAllocation, $lead.creditsUsed ) )
${creditsRemaining} credits remaining this month</code></pre><p>So you figure you’ll pop that in the native Preheader area:</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/05/image-4.png" class="kg-image" alt="" loading="lazy" width="879" height="909" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2026/05/image-4.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/05/image-4.png 879w" sizes="(min-width: 720px) 720px"><figcaption><span style="white-space: pre-wrap;">Trying to use </span><code spellcheck="false" style="white-space: pre-wrap;"><span>{{my.tokens}}</span></code><span style="white-space: pre-wrap;"> in the Preheader</span></figcaption></figure><p>Oops! That won’t work. Velocity <code>{{my.tokens}}</code> <mark><em>aren’t treated as tokens </em>in the built-in Preheader</mark>. <em> </em>Technically you <em>can </em>paste ’em in there, they just get treated as literal curly braces with some words inside:</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/05/image-7.png" class="kg-image" alt="" loading="lazy" width="1726" height="439" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2026/05/image-7.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2026/05/image-7.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1600/2026/05/image-7.png 1600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/05/image-7.png 1726w" sizes="(min-width: 720px) 720px"><figcaption><span style="white-space: pre-wrap;">You think it’s a </span><code spellcheck="false" style="white-space: pre-wrap;"><span>{{my.token}}</span></code><span style="white-space: pre-wrap;">, but to Marketo it’s just text!</span></figcaption></figure><p>I was set to present a fairly tricky workaround when a tip from my friend <a href="https://motam.co.nz/?ref=blog.teknkl.com" rel="noreferrer">Jo @ MoTaM</a> led to a simpler one that tested out perfectly.</p><h2 id="a-simple-workaround">A simple workaround</h2><p>You should know by now that <mark>a Marketo email is one big Velocity template.</mark> Even with no <em>user-authored</em> Velocity code, the <em>system </em>still uses Velocity to render emails. (No coincidence that email <code>${variable}</code> syntax resembles Velocity <code>${reference}</code> syntax!)</p><p>To elegantly combine userland token code with internal system code, Marketo uses “multipass” Velocity rendering, which I’ve explored in other posts. In brief, this means at some points in the rendering process, Velocity Template Language is interpreted (i.e. run) as code, while at other points it’s just text. It’s a very complex stack.</p><p>Relevant today is that despite appearing at the top of the <code>&lt;body&gt;</code>, the native Preheader section actually renders <em>after </em>an initial pass through userland <code>{{my.tokens}}</code>. It can’t run <code>{{my.tokens}}</code> itself, but <mark>it does have have access to Velocity <code>${references}</code> previously set in <code>{{my.tokens}}</code>.</mark> (This is despite the preheader having a different initial Velocity <a href="https://velocity.apache.org/engine/1.7/apidocs/org/apache/velocity/VelocityContext.html?ref=blog.teknkl.com" rel="noreferrer">context</a> from <code>{{my.tokens}}</code>. Look, it’s pretty weird.😜)</p><p>So have your <code>{{my.token}}</code> <mark>set a <code>$string</code> </mark>instead of outputting:</p><pre><code class="language-velocity">#set( $creditsRemaining = $math.sub( $lead.monthlyAllocation, $lead.creditsUsed ) )
#set( $preheader = "${creditsRemaining} credits remaining this month" )</code></pre><p>Then reference that <code>${string}</code> in the Preheader section:</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/05/image-3.png" class="kg-image" alt="" loading="lazy" width="879" height="906" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2026/05/image-3.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/05/image-3.png 879w" sizes="(min-width: 720px) 720px"><figcaption><span style="white-space: pre-wrap;">Aha! You can use a previously declared Velocity </span><code spellcheck="false" style="white-space: pre-wrap;"><span>${reference}</span></code><span style="white-space: pre-wrap;"> instead </span></figcaption></figure><h2 id="another-collateral-improvement">Another collateral improvement</h2><p>Velocity also lets you sidestep the restriction on raw <code>{{lead.tokens}}</code> in the native Preheader:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/05/image-6.png" class="kg-image" alt="" loading="lazy" width="771" height="205" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2026/05/image-6.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/05/image-6.png 771w" sizes="(min-width: 720px) 720px"></figure><p>This is one of those “implicit resource governor” things in Marketo, where by limiting to triggers there’s less database work. (Another example is <code>{{lead.tokens}}</code> in <em>Send Alert </em>only working w/triggers.<em>) </em></p><p>We use <em>Request Campaign </em>to get around those anyway, so might as well take the short route.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ List-Unsubscribe-Post unsubscribes (e.g. clicking “Unsubscribe” in Gmail) won’t work if your MAP is down ]]></title>
        <description><![CDATA[ Not sure anyone’s tested this before. Unwanted result, but at least we can plan accordingly. ]]></description>
        <link>https://blog.teknkl.com/list-unsubscribe-post-wont-work-if-map-is-down/</link>
        <guid isPermaLink="false">6a01ee07bedfbc0001cfa275</guid>
        <category><![CDATA[ List-Unsubscribe ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Fri, 15 May 2026 23:14:43 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/05/client_bugs.png" medium="image"/>
        <content:encoded><![CDATA[ <p>With network maintenance coming up on one Marketo pod, I was asked whether any features <em>wouldn’t </em>be affected during the maintenance period.</p><p>First instinct: <strong><em>Nope.</em> </strong>Everything uses the network, after all: outbound sends, SFDC sync, inbound LPs and forms, API calls and webhooks, etc. But one feature was unclear: <mark>unsubscribes driven by the <code>List-Unsubscribe-Post</code> SMTP header</mark>. Don’t think anyone looked into this before (like, anywhere).   </p><p>As you probably know, MAPs like Marketo comply with major email providers’ <a href="https://support.google.com/a/answer/81126?sjid=13344868810064956870-NA&ref=blog.teknkl.com#:~:text=Make%20it%20easy%20to%20unsubscribe" rel="noreferrer"><code>List-Unsubscribe</code> requirement</a> for optimal inboxing. They include a <code>List-Unsubscribe-Post=One-Click</code> header, plus a <code>List-Unsubscribe</code> header with a unique <code>&lt;https:&gt;</code> URL. Optionally, the old-school <code>&lt;mailto:&gt;</code> URL can also be included. Here’s an example:</p><pre><code>List-Unsubscribe-Post: List-Unsubscribe=One-Click
List-Unsubscribe: &lt;https://click.example.com/u/NDEwLVhPUi02NzMAAAGhsh1UUD4gY6H_B7Njx0Jc6yQJgFRnh79OM35fojpoyZioqvs37SuRJd8HZ1U99J58MdA5RZg=?emailAddress=NDEwLVhPUi02NzMAAAGhsh1UT80EfvxIOM_0IIgrb6o4XgtlEXp3uUVXGC4TOThaLUIpcA6xLX0YEMvmhz1c22mwu49ropgwa6olwA&gt;, &lt;mailto:GE4C2YJQNZRVAM3RIZIFE4TMJ5VGM3TQKZTT2PI=.315939.132780.9@unsub-sj.mktomail.com&gt;</code></pre><p>Then, when someone clicks the <em>Unsubscribe </em>link in Gmail et al., the underlying client infrastructure (e.g. Gmail itself) sends an HTTP POST to the URL.<a href="#notes" rel="noreferrer">[1]</a> Marketo receives the POST request, flips <em>Unsubscribed </em>to <code>true</code>, and of course you see it in the Activity Log.</p><p>Works perfectly in daily operation, but I was curious what would happen if <code>click.example.com</code> didn’t respond affirmatively, either due to network downtime or explicit HTTP 4xx/5xx error. Would the client retry the HTTP POST method after some period of time, and when would it give up? Would it fall back to the SMTP method?<a href="#notes" rel="noreferrer">[2]</a></p><p>Unfortunately — in line with my immediate <em><strong>Nope </strong>— </em>my tests showed <mark>the client would never retry</mark>. Yet they <em>would </em>claim the person unsubscribed, which isn’t great:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/05/image-2.png" class="kg-image" alt="" loading="lazy" width="1236" height="369" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2026/05/image-2.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2026/05/image-2.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/05/image-2.png 1236w" sizes="(min-width: 720px) 720px"></figure><h2 id="they%E2%80%99re-neither-wrong-nor-right">They’re neither wrong nor right </h2><p><code>List-Unsubscribe-Post</code> is standardized in <a href="https://www.rfc-editor.org/rfc/rfc8058.html?ref=blog.teknkl.com" rel="noreferrer">RFC 8058</a>, which makes no mention of retries one way or the other. So it’s unfortunate for us on the MOps side, but you really can’t scream about it. </p><h2 id="dealing-with-it">Dealing with it</h2><p>One of our clients was concerned enough to have us implement a workaround. (Other clients weren’t so moved.😉) What we did: <mark>redirected the branding domain to a simple static webpage with webserver logs turned on</mark> and a message “Temporary maintenance: please refresh this page after 11am Pacific today.”</p><p>That lets us (1) replay all the unsubscribes ourselves after the maintenance period and (2) replay regular email clicks to see if there were enough missed clicks to merit a follow-up email to that group. </p><h5 id="notes">Notes</h5>
<p>[1] If you’re curious, the POST looks like this:</p><pre><code>POST /u/NDEwLVhPUi02NzMAAAGhuFmGynE1xSHPBcMa7aLZ4Ay0RYBJ-1qqgHq0fauXbb_GWMG25zB7jBG_mB0xvVrYDFKadNI=?emailAddress=NDEwLVhPUi02NzMAAAGhuFmGykdqCBuzmrzNKKwgr6KsTU73Wa3KuMuCm2Y4h2_v8IBCklTna92hF8EcH1pG51rSNsdN2SPdUzuVsw HTTP/1.1
Host: click.example.com
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/1.0 (KHTML, like Gecko; Gmail Actions)
Content-Length: 26
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded

List-Unsubscribe=One-Click</code></pre><p>[2] Ironically more resilient, since transient SMTP delivery failures are retried!</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Make native Pre-Fill work across refreshes with a few lines of JS ]]></title>
        <description><![CDATA[ Native Pre-Fill’s delicate, everything-must-be-just-right rules are accepted as the cost of privacy. But you don’t have to live with one of the more frivolous ones. ]]></description>
        <link>https://blog.teknkl.com/make-native-pre-fill-work-pon-de-refresh/</link>
        <guid isPermaLink="false">69dd49c101fd7d000190aa0b</guid>
        <category><![CDATA[ Pre-Fill ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Tue, 14 Apr 2026 01:45:06 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/04/formsjs.png" medium="image"/>
        <content:encoded><![CDATA[ <p>Marketo’s hardcore rules for native Pre-Fill make sense, in particular stripping the <code>mkt_tok</code> query param so people <mark>don’t accidentally send their own personalized link</mark> to someone else.</p><p>Many users augment the native feature using my <a href="https://blog.teknkl.com/vital-update-to-simpledto-form-pre-fill-js-for-new-versions-of-chrome-edge/" rel="noreferrer">SimpleDTO</a> library, which enables Pre-Fill even on non-Marketo pages. (And with infinite scale, since it doesn’t use the REST API.) But if you’re not ready for SimpleDTO, there’s one hiccup in the native implementation — a bug in my view, since it has no privacy or security benefit — you can fix with a little JS.</p><p>What’s the bug? Well, even when Pre-Fill works perfectly on an initial pageview:<a href="#notes" rel="noreferrer">[1]</a></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/04/image-2.png" class="kg-image" alt="" loading="lazy" width="1211" height="1118" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2026/04/image-2.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2026/04/image-2.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/04/image-2.png 1211w" sizes="(min-width: 720px) 720px"></figure><p>Just refreshing the page breaks it: </p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/04/image-1.png" class="kg-image" alt="" loading="lazy" width="1211" height="1118" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2026/04/image-1.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2026/04/image-1.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/04/image-1.png 1211w" sizes="(min-width: 720px) 720px"></figure><p>Now, the <em>technical </em>reason for this is clear: the always-added <strong><em>stripmkttok.js </em></strong>removed the <code>mkt_tok</code> for safety. (Which, again, is a good thing.) So upon refresh, the page no longer meets the prereqs for Pre-Fill.</p><p>But the UX is needlessly punitive.  The person refreshing the page in their browser — the page that <em>just </em>included their data — isn’t expecting the data to be gone. Merely re-pre-filling the form after refresh doesn’t pose any risk, long as there aren’t any side effects. </p><p>You may think I’m about to suggest using session cookies or session storage to preserve the data. Nope! You don’t need either of those, and since they’re subject to cookie consent policies, they aren’t reliable anyway. (Even with consent, they also represent a “side effect” in the sense that the data <mark>would persist across <em>other </em>pageviews in the session</mark> unless deliberately deleted.)</p><p>All you need is <a href="https://developer.mozilla.org/en-US/docs/Web/API/History/state?ref=blog.teknkl.com" rel="noreferrer"><code>history.state</code></a>. </p><h2 id="historystate-is-the-narrowest-client-side-storage-mechanism-thus-perfect-here"><code>history.state</code> is the narrowest client-side storage mechanism (thus perfect here)</h2><p>While used extensively by Single Page Applications (SPAs), the History API is almost never used on landing pages. Even with SPAs, it’s usually tapped by frameworks under the hood, so coders rarely understand it.<a href="#notes" rel="noreferrer">[2]</a></p><p>In brief, with <code>history.state</code> you <mark>add JS data to browser session history</mark> so when someone returns to a previously visited entry (including refreshing) the data is restored as well. A <code>state</code> isn’t readable from any other position in the navigation history. And it’s deleted completely when the browser closes or history is deliberately cleared.<a href="#notes" rel="noreferrer">[3]</a></p><p>So that’s perfect for attaching Pre-Fill data <em>only </em>to the current entry without risk of leaks. Note I really mean the current entry: <code>history.state</code> attached to <code>https://pages.example.com/mypage.html</code> isn’t even available on a separate visit to <code>https://pages.example.com/mypage.html</code>, let alone on <code>https://pages.example.com/mypage.html?somethingelse</code>. It’s locked down to one history entry!</p><p>The catch: <mark>we must attach <code>history.state</code> to the URL <em>without the <code>mkt_tok</code></em></mark>, i.e. <em>after </em>the stripping happens<em>. </em>Trickier than it sounds, since <strong><em>stripmkttok.js </em></strong>is injected at the very bottom of the document. Our code will thus always run first; if we merely add a <code>load</code> listener, that’ll run before their <code>load</code> listener, too.</p><p>Luckily, there’s a way to force our stuff to run last: in a <code>load</code> listener, call <code>setTimeout</code> with a delay of <code>0</code>. (No, this is not an arbitrary wait, which you know I hate from other posts. It’s an exact order of operations!)</p><p>Put this code in a <code>&lt;script&gt;</code> at the very top of the <code>&lt;body&gt;</code>:<a href="#notes" rel="noreferrer">[4]</a> </p><pre><code class="language-javascript">if( window.mktoPreFillFields ) {
  window.addEventListener("load", (e) =&gt; {
    const state = Object.assign({}, history.state, { mktoPreFillFields });
    setTimeout( () =&gt; history.replaceState(state , "", document.location.href), 0);
  });
} else {
  window.mktoPreFillFields = history.state?.mktoPreFillFields;  
}</code></pre><p>Enjoy!</p><h5 id="notes">Notes</h5>
<p>[1] The form in the screenshot also uses custom code to set <em>Email </em>to read-only if it’s Pre-Filled.</p><p>[2] Plus it isn’t even possible to introspect in DevTools, making it very black-boxy.</p><p>[3] And after periods of inactivity, depending on the browser.</p><p>[4] <code>window.mktoPreFillFields</code> must be available when forms render lower in the <code>&lt;body&gt;</code>.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ “Disabling cookies” and “Disabling Munchkin” are different (though of course related) things ]]></title>
        <description><![CDATA[ Consistently seeing only one pageview per visit (not zero, but never &gt; 1)? It’s probably cookie consent JS running amok. ]]></description>
        <link>https://blog.teknkl.com/disabling-cookies-and-disabling-munchkin-are-different-though-of-course-related-things/</link>
        <guid isPermaLink="false">69a6813fe996da00010c42ac</guid>
        <category><![CDATA[ Munchkin ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Tue, 07 Apr 2026 12:57:08 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/03/browserandurlstuff.png" medium="image"/>
        <content:encoded><![CDATA[ <p><strong><em>“We’re consistently seeing only one pageview per visit,” </em></strong>noted the Marketo admin. <strong><em>“We can’t be offending <u>everybody</u>, so maybe something’s up with our cookie banner?”</em></strong></p><p>She was right, people weren’t abandoning immediately. Rather, broken cookie consent JS was blocking <em><mark>the Munchkin <code>_mkto_trk</code> cookie </mark><mark>for everyone, </mark><mark>but not blocking Munchkin itself</mark></em>.</p><h2 id="how-not-to-manage-cookie-consent">How <em>not </em>to manage cookie consent</h2><p>The code, like others of its type, was jaw-droppingly intrusive: it redefined <code>document.cookie</code> and proxied <code>cookieStore</code> to eavesdrop on set-cookie calls. (A crazy thing to just drop into a site, not that tricks used by other cookie apps<a href="#notes" rel="noreferrer">[1]</a> are much better!)</p><p><code>_mkto_trk</code> was on its cookie blocklist, but <code>munchkin.js</code> was always loaded + initialized. That leads to a half-baked outcome where:</p><ul><li>a person lands on the site from an email link, which attaches the <code>mkt_tok</code> to the query string to identify the Marketo record</li><li>Munchkin generates a random session token value</li><li>saving the token to a cookie is blocked by the consent JS</li><li>the token value is nevertheless used in <em>Visit Web Page </em>hits for that pageview, together with the <code>mkt_tok</code> </li><li>the token value is also used in <em>Click Link </em>hits for that pageview</li><li>on subsequent pageviews, Munchkin doesn’t see an existing cookie so generates a <em>new </em>random token value</li><li>as those later pageviews have no identifying <code>mkt_tok</code>, their <em>Visit Web Page/Click Link </em>activities stay anonymous forever!</li></ul><p>Here’s a browser log for 4 successive page refreshes with this unfortunate setup. Note how the session token value (transported to Marketo as <code>_mchTk</code>) changes each time:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/03/image-7.png" class="kg-image" alt="" loading="lazy" width="1980" height="402" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2026/03/image-7.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2026/03/image-7.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1600/2026/03/image-7.png 1600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/03/image-7.png 1980w" sizes="(min-width: 720px) 720px"></figure><p>This reveals the token isn’t directly dependent on cookie support. Instead, think of it as a <mark>“Munchkin session identifier, <em>potentially </em>stored in a cookie”</mark>.</p><h2 id="differential-diagnosis-iframe">Differential diagnosis: IFRAME </h2><p>Browser security policies can cause this same behavior, no cookie banner involved. If you IFRAME a Marketo LP from <code>https://pages.example.com</code> inside a cross-domain, cross-origin page like <code>https://whatever.example.io</code>, then Munchkin on the Marketo LP will generate and use a new session token for that pageview, but won’t be allowed to save a cookie.</p><p>If the person later goes to <code>https://www.example.com</code>, they’ll get a new random session token and their actions will be anonymous. Even though in the broad sense <code>www.example.com</code> and <code>pages.example.com</code> share cookies from the parent <code>example.com</code>, that only works if the browser lets you <em>set </em>the cookie in the first place!<a href="#notes" rel="noreferrer">[2]</a></p><h2 id="differential-diagnosis-plugins">Differential diagnosis: Plugins</h2><p>Browser plugins can cause this same behavior (though they wouldn’t affect 100% of visitors). For example, <a href="https://addons.mozilla.org/en-US/firefox/addon/cookiebro/?ref=blog.teknkl.com" rel="noreferrer">Cookiebro</a> with a custom blocklist:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/04/Untitled--10-.png" class="kg-image" alt="" loading="lazy" width="1521" height="1147" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2026/04/Untitled--10-.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2026/04/Untitled--10-.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/04/Untitled--10-.png 1521w" sizes="(min-width: 720px) 720px"></figure><h5 id="notes">Notes</h5>
<p>[1] Like shadowing <code>document.createElement</code> and using MutationObserver to neuter <code>&lt;script&gt;</code> tags. Ugh.</p><p>[2] If you IFRAME <code>https://pages.example.com</code> inside <code>https://www.example.com</code>, you <em>can </em>set a cookie at <code>example.com</code> from inside the frame — though in that case it makes more sense to set the cookie in the parent document before injecting the IFRAME. But that’s not the same as <code>https://pages.example.com</code> inside <code>https://whatever.example.io</code> since in the latter case <mark>the pages have no common private domain suffix.</mark></p><p>Note first-party-cookie-from-third-party-IFRAME rules have gotten stricter over time. Browsers from 5+ years ago could do more damage! </p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Route Marketo forms based on business hours ]]></title>
        <description><![CDATA[ A little bit o’ Intl.DateTimeFormat on the browser side and presto: trigger campaigns with unprecedented power. ]]></description>
        <link>https://blog.teknkl.com/route-marketo-forms-based-on-business-hours/</link>
        <guid isPermaLink="false">69ab487dd2eb4a0001c46a96</guid>
        <category><![CDATA[ date filters ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Sat, 28 Mar 2026 16:36:00 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/03/formsjs-1.png" medium="image"/>
        <content:encoded><![CDATA[ <p>Smart Lists offer a dizzying array of filters (<em>In Past, In Past Before, In Date Range, </em>that stuff) for Date and DateTime fields. Yet you can’t natively check if <em>the current system time — </em>that is, <mark>the time a campaign is triggered — is within a certain time range on a certain day</mark>.</p><p>Even if you save <code>{{system.DateTime}}</code> to a field, native filters lack <em>hour-of-day </em>and <em>day-of-week </em>awareness. Obviously, you need both to determine if a form came in during business hours and should be promptly <em>Alert-</em>ed to sales vs. <em>Wait-</em>ed until next business day.<a href="#notes" rel="noreferrer">[1]</a></p><p>Luckily, there’s a workaround for <em>Fills Out Form </em>triggers via a little JS. </p><h2 id="create-a-helper-field">Create a helper field</h2><p>First, create a new <strong>String </strong>(not Date or DateTime) field <em>Filterable Form Timestamp.</em></p><h2 id="add-the-js">Add the JS</h2><p>Then add this code to pages with time-sensitive forms:</p><pre><code class="language-javascript">MktoForms2.whenReady(function(readyForm){
   const minIncrement = 30; // easier filtering than every minute
   const ianaTimeZone = "America/New_York"; // working hours in this timezone

   readyForm.onSubmit(function(submittingForm){
      const now = new Date();
      const formatter = new Intl.DateTimeFormat("en-US", { hourCycle: "h23", hour: "numeric", minute: "numeric", weekday: "long", timeZone: ianaTimeZone });

      const partsMap = new URLSearchParams(formatter.formatToParts(now).map( ({ type,value }) =&gt; [type,value] ));     
      const minuteClamped = Math.floor(partsMap.get("minute") / minIncrement) * minIncrement;

      const filterable = `${partsMap.get("hour")}:${minuteClamped.toString().padStart(2,"0")} ${partsMap.get("weekday")}`;

      submittingForm.addHiddenFields({
         filterableFormTimestamp: filterable
      });
   });
});</code></pre><p>(Or just add it to all pages with forms if that’s easy in your environment.)</p><p>Now, <em>Filterable Form Timestamp </em>will have values formatted as <code>&lt;HH&gt;:&lt;mm&gt; &lt;day of week&gt;</code>, e.g. <code>11:30 Wednesday</code> or <code>22:00 Sunday</code>.  That specific format<a href="#notes" rel="noreferrer">[2]</a> is easy to use in [starts with] and<em> </em>[contains] filters, as we’ll see below.</p><h2 id="about-the-minincrement-and-ianatimezone-options">About the <code>minIncrement</code> and <code>ianaTimeZone</code> options</h2><p>Recommend <code>minIncrement</code> be left at <code>30</code>. This means <mark>11:30am, 11:40am, and 11:55am are all logged as <code>11:30</code></mark>. You can drop <code>minIncrement</code> to <code>1</code> so the exact minute is stored, but you rarely need such granularity and some filters get more complex.<a href="#notes" rel="noreferrer">[3]</a></p><p><code>ianaTimeZone</code> is the <mark>timezone of the relevant business hours</mark>, <em>not </em>the browser’s local timezone. Might correlate to a <em>Region </em>selector that’s also on the form, so you can grab it from there. (Note prior to 2017, there was no native way to format dates in an arbitrary tz; you could only format in local time or in UTC. Luckily, we now have <code>Intl.DateTimeFormat</code>.)</p><h2 id="tweak-your-campaigns-to-match-the-field">Tweak your campaigns to match the field </h2><p>Now let's say your business hours are <mark>8:30am-6pm Mon-Fri and 12-6pm Saturday</mark>. Match those conditions using this filter combo:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/03/image-5.png" class="kg-image" alt="" loading="lazy" width="1404" height="1146" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2026/03/image-5.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2026/03/image-5.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/03/image-5.png 1404w" sizes="(min-width: 720px) 720px"></figure><p>Closeup of the multiple values dialog for filter #2:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/03/image-3.png" class="kg-image" alt="" loading="lazy" width="1209" height="759" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2026/03/image-3.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2026/03/image-3.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/03/image-3.png 1209w" sizes="(min-width: 720px) 720px"></figure><p>Pretty cool, right?</p><h2 id="why-not-do-full-%E2%80%9Cis-it-business-hours%E2%80%9D-calculation-in-js">Why not do full “Is it business hours?” calculation in JS?</h2><p>Sure, you could calculate in the browser and store a hidden Boolean like <em>Was Last Form Fill During Business Hours</em>. But that means <mark>any <em>change </em>to business hours requires a change to the JS config</mark>. More manageable to keep JS data-only while business logic lives in Marketo.</p><h2 id="why-parse-to-urlsearchparams-instead-of-a-map-or-regular-object">Why parse to <code>URLSearchParams</code> instead of a <code>Map</code> or regular <code>Object</code>?</h2><p>Trade secret.😛 (Will explain in a future post. Guesses welcome in the comments!)</p><h5 id="notes">Notes</h5>
<p>[1] You’ll see old community posts which purport to solve the problem using <em>Advanced Wait</em>, but they don’t actually work! Namely, they don’t fulfill the <em>hour-of-day </em>requirement. </p><p>[2] <em>Filterable Form Timestamp </em>is meant to be machine-readable (that is, Smart List-readable), not something you’d include in an alert. For alerts, store the full UTC submission time in a DateTime field:</p><pre><code class="language-javascript">MktoForms2.whenReady(function(readyForm){
  readyForm.onSubmit(function(submittingForm){
    submittingForm.addHiddenFields({
      lastFormSubmissionTS: new Date().toISOString()
    });
  });
});</code></pre><p>Then you can format that however you want with a Velocity <code>{{my.token}}</code>. </p><p>[3] To match 8:30am-8:59am with a <code>minIncrement = 1</code>, you need [starts with] <code>08:3; 08:4; 08:5</code>. In contrast, with <code>minIncrement = 30</code> you just need [starts with] <code>08:30</code>.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Another case where you should disable (automatic) Munchkin on a Marketo LP and manually load it instead ]]></title>
        <description><![CDATA[ There are enough cases where you should turn off automatic Munchkin that I’m close to saying “always load it manually.” ]]></description>
        <link>https://blog.teknkl.com/another-case-where-you-should-disable-automatic-munchkin-on-a-marketo-lp-and-manually-load-it-instead/</link>
        <guid isPermaLink="false">69c567c9928ec30001f1f820</guid>
        <category><![CDATA[ Munchkin ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Thu, 26 Mar 2026 15:34:20 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/05/munchkin.png" medium="image"/>
        <content:encoded><![CDATA[ <p>You should already know a <mark>non-default <code>domainLevel </code></mark>(for example, an <code>.io</code> or <code>.ai</code> domain) requires customizing the Munchkin embed code. Easy on a 3rd-party site, where you control the embed code. But on a Marketo LP, you must <em>disable </em>Munchkin at the template level — <em>Template Actions » Disable Munchkin Tracking </em>— to substitute your custom version.<a href="#notes" rel="noreferrer">[1]</a></p><p>Another case for disabling auto-Munchkin and injecting it yourself is managing cookie consent.<a href="#notes" rel="noreferrer">[2]</a></p><p>Then there’s today’s case: if you’re using (as you should) a <a href="https://blog.teknkl.com/marketo-redirector-page-redux/" rel="noreferrer">Redirector LP</a> for email links to downloads, where the next-hop URL is <mark>passed via the URL hash</mark>, you <em>must </em>use custom Munchkin to see the logs you expect.</p><h2 id="why-use-the-hash-though"><em>Why </em>use the hash, though?</h2><p>First of all, using the hash, like</p><pre><code class="language-markup">https://pages.example.com/download#https://assets.example.com/my.pdf</code></pre><p>offers better <em>caching </em>than using a query param. The hash is never sent outside the browser, so a cached version of <code>https://pages.example.com/download</code> can be used.<a href="#notes" rel="noreferrer">[3]</a></p><p>But more important, <mark>Marketo defines the “web page” in a <em>Visit Web Page </em>activity as the hostname + pathname + hash</mark>, but <em>not </em>the query string. When reading the logs, it’s easier to see the “real” URL (i.e. download URL) if it’s not buried in the details.</p><h2 id="the-relevant-diff-between-auto-munchkin-and-custom-munchkin">The relevant diff between auto-Munchkin and custom Munchkin</h2><p>A previously-undocumented wrinkle is that the desired behavior, where the hash is part of the <em>Visited Web Page</em>, doesn’t work by default on Marketo LPs! When <code>customName</code> and <code>wsInfo</code> options are passed, as is the case with auto-injected Munchkin, the hash is removed from the logs completely:</p><pre><code class="language-javascript">Munchkin.init('123-ABC-456', {customName: 'RedirectorPage', wsInfo: 'j1RR'})</code></pre><p>But manual Munchkin doesn’t know/use those values, so Marketo treats the pageview as if it’s on a 3rd-party site and <em>does </em>log the hash:</p><pre><code class="language-javascript">Munchkin.init('123-ABC-456');</code></pre><h5 id="notes">Notes</h5>
<p>[1] Oversimplifying a bit: to get the benefits of automatic Munchkin while customizing options, you can’t just switch from automatic → embed code but need to <a href="https://blog.teknkl.com/conditionally-loading-munchkin-on-marketo-lps-for-gdpr-related-policies/" rel="noreferrer">merge the two</a>. However, for the redirector page case, you deliberately <em>don’t </em>want to merge, just switch.</p><p>[2] Venture 50%+ of people who <em>think </em>they don’t load Munchkin if someone rejects cookies are failing to observe the user’s choices due to a race condition on Marketo LPs, i.e. the cookie’s already been set when the banner pops up. Custom code lets you use the consent software’s declarative syntax like <code>&lt;script type="conditional:tracking"&gt;</code>.</p><p>[3] In fairness, the <code>mkt_tok</code> is always changing in this case too, which counteracts the caching part, but it’s still worth doing.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Decay scores by a percentage (rather than a fixed decrement) using FlowBoost ]]></title>
        <description><![CDATA[ Like it says on the tin. ]]></description>
        <link>https://blog.teknkl.com/decay-scores-by-a-percentage-rather-than-a-fixed-decrement-using-flowboost/</link>
        <guid isPermaLink="false">69ade2dfd2eb4a0001c46dc8</guid>
        <category><![CDATA[ FlowBoost ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Sat, 07 Mar 2026 16:59:00 -0500</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/03/flowboostjs_new.png" medium="image"/>
        <content:encoded><![CDATA[ <p>Did I never write this up before? Crazy.</p><p>So you have a complex (up)scoring matrix, varying weights for high-value engagement and all that. But your decay/downscoring logic is one-size-fits-all: <strong><em>“Subtract 5 points after each month of inactivity.”</em></strong></p><p>Doesn’t make much sense IMO. Don’t you want to push higher scores down faster than lower scores, in relative terms? Otherwise, people linger on the high end forever, distorting their value.</p><p>Here’s an example of <em>percentage-based decay </em>using FlowBoost:</p><pre><code class="language-javascript">originalScore = Number({{lead.Lead Score}}),
decrement = Math.max(Math.trunc(originalScore * .05), 5);
newScore = originalScore - decrement;
</code></pre><p>This’ll decay the score <mark>by 5 points or by 5%, whichever is larger</mark>. Play with thresholds as you wish. Think you’ll find a big advantage over fixed decrements.  </p><h2 id="related-rant">Related rant</h2><p>Scoring is still remarkably mushy. Most people’s Marketo scoring campaigns aren’t based on empirical conversion trends, nor on continuous input from sales! A reminder that automation is only as good as data. Or something.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ HOWTO: Use Design Studio-hosted webfonts in Stensul/Knak/etc. previews ]]></title>
        <description><![CDATA[ Marketo-integrated page builders like Stensul &amp; Knak publish your pages as Marketo LPs. Published LPs fully support webfonts hosted in Marketo Design Studio, since they’re on the same domain.

But what about inside the builder app, when you’re still previewing? There, you’ll have a problem. Even ]]></description>
        <link>https://blog.teknkl.com/using-design-studio-hosted-webfonts-in-stensul-knak-etc-previews/</link>
        <guid isPermaLink="false">699fcab3e996da00010c4138</guid>
        <category><![CDATA[ Stensul ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Thu, 26 Feb 2026 13:56:21 -0500</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/02/otherbuilderstuff.png" medium="image"/>
        <content:encoded><![CDATA[ <p>Marketo-integrated page builders like Stensul &amp; Knak publish your pages as Marketo LPs. Published LPs fully support webfonts hosted in Marketo Design Studio, since they’re on the same domain.</p><p>But what about <em>inside </em>the builder app, when you’re still previewing? There, you’ll have a problem. Even with the correct <code>@font-face</code> rule in your CSS, the font won’t load because of CORS restrictions:</p><pre><code class="language-markup">❌ Access to font at 'https://pages.example.com/rs/123-ABC-456/images/entypo.woff' from origin 'https://app.stensul.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.</code></pre><p>This is reasonable, of course. Marketo isn’t in the business of providing an easily abused font CDN to the public!</p><p>There’s a slick workaround. You can’t control CORS headers, but you can (by definition) host a page with same-origin access to the fonts. By deploying such a page as a hidden IFRAME, you can shuttle the fonts to a parent page, even if the parent is on a different origin.</p><p>So create a simple “font server” webpage that just runs this JS (doesn’t even have to be a true Landing Page, it could be an <code>.html</code> file in Design Studio):</p><pre><code class="language-javascript">// substitute your page builder domain
const fontClientOrigin = "https://company.stensul.com";

const fontFamilies = new Set();
fontFamilies.add({
  name: "famo", 
  url: "https://assets.codepen.io/250687/7d25ff4e-2756-4745-b0ef-ac83530b35b6.ttf"
});
fontFamilies.add({
  name: "lano", 
  url: "https://assets.codepen.io/250687/39C54B_0_0.ttf",
  descriptors: { style: "italic" }
});
/* ... more fonts */

for( const {name, url, descriptors} of fontFamilies ) {
  fetch( url )
  .then( (resp) =&gt; resp.blob() )
  .then( (blob) =&gt;
      blob.arrayBuffer()
      .then( (asBuffer) =&gt; window.parent.postMessage({ name, asBuffer, descriptors }, fontClientOrigin, [asBuffer]) )
  );
}</code></pre><p>Then add this code to your landing pages:</p><pre><code class="language-javascript">function initXDFontClient(serverURL){

  const fontServerURL = new URL(serverURL);
  const fontServerIframe = document.createElement("iframe");
  fontServerIframe.style.display = "none";
  fontServerIframe.src = fontServerURL.href;
  document.body.append(fontServerIframe);

  window.addEventListener("message", ({ source, origin, isTrusted, data: font }) =&gt; {
    if( source !== fontServerIframe.contentWindow || origin !== fontServerURL.origin || !isTrusted ) return;

    const face = new FontFace(font.name, font.asBuffer, font.descriptors)
    document.fonts.add(face);
  });

}

// substitute your page builder domain
if( document.location.origin == "https://company.stensul.com" ) {  
  initXDFontClient("https://pages.example.com/iframe-font-server.html");
}</code></pre><p>The “font client” gets inbound messages containing a font name + binary font data and dynamically adds a corresponding <code>FontFace</code>.<a href="#notes" rel="noreferrer">[1]</a></p><p>What I particularly like is both “server” and “client” parts use minimal serialization. The server converts the fetched Blob into an ArrayBuffer, but the AB is transferred directly to the client. The client instantiates the font directly from the AB. Not that serialization (i.e. to <code>data:</code> URIs) would be catastrophic but it’s nice to do without.</p><h2 id="yes-there%E2%80%99s-a-fout-flash-of-unstyled-text">Yes, there’s a <em>FOUT </em>(Flash of Unstyled Text)</h2><p>There’s a sub-second delay while Marketo-hosted webfont(s) are loading when fallback fonts are used.</p><p>That’s a natural side effect of (a) loading fonts asynchronously while not (b) hiding content until fonts are ready. The code isn’t running in production anyway, only for previews in the page builder, so shouldn’t be a problem!<a href="#notes" rel="noreferrer">[2]</a></p><h5 id="notes">Notes</h5>
<p>[1] You only need one font format for this case, preferably WOFF2. Remember, the IFRAME isn’t used on your published pages. </p><p>[2] There are ways to eliminate the FOUT. One (lightly tested) way is caching <code>data:</code> URIs in LocalStorage for each font. Then add rules to a <a href="https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet/replaceSync?ref=blog.teknkl.com" rel="noreferrer">CSStyleSheet</a> on page load.</p><p>Note using the Cache API to store constructed Responses, cool as that would be, won’t work here because it’s always asynchronous. </p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ My upcoming talk at the NYC MUG (since rescheduled) ]]></title>
        <description><![CDATA[ This is next Wednesday. (Hey, no one ever said I was good at marketing.) ]]></description>
        <link>https://blog.teknkl.com/im-giving-a-talk-at-the-nyc-mug/</link>
        <guid isPermaLink="false">6996031b2835b10001e90248</guid>
        <category><![CDATA[ NYC MUG ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Thu, 19 Feb 2026 14:56:00 -0500</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/02/liveevents.png" medium="image"/>
        <content:encoded><![CDATA[ <p>On <s>Wednesday, February 25th</s> Thursday, March 12th, I’m covering <strong><em>Marketo Rediscoveries and Mismemories</em></strong> at Adobe HQ in midtown.</p><p>It’s my list of 10 quirky details even Marketo vets forget, plus a few fictions you might <em>think </em>you remember... except the info is off and/or outdated.</p><p>Useful for any skill level, including past masters who’ve been away from the platform for a while. RSVP <a href="https://mugs.marketo.com/events/details/marketo-new-york-mug-presents-february-2026-nymug-in-person-marketo-mismemories-and-rediscoveries/?ref=blog.teknkl.com" rel="noreferrer"><strong>here</strong></a>.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ A Redirector LP for HubSpot that isn’t lying to you ]]></title>
        <description><![CDATA[ Hubspot, Marketo, and other MAPs share the same issue when you link to assets like PDFs. The click is logged, but further session tracking is impossible. That’s solved by a Redirector LP. ]]></description>
        <link>https://blog.teknkl.com/a-redirector-lp-for-hubspot-that-isnt-lying-to-you/</link>
        <guid isPermaLink="false">695b62cf5a56430001dee9c4</guid>
        <category><![CDATA[ hubspot ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Thu, 19 Feb 2026 14:55:21 -0500</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/01/analytics.png" medium="image"/>
        <content:encoded><![CDATA[ <p>Sorry to be harsh, but the world of “user-contributed code” for Hubspot is really amateurish. (Plenty of bad Marketo JS out there, including my own old stuff, but it’s brilliant by comparison.)</p><p>Anyway, a friend asked if I’d ever written Redirector LP code for HubSpot like <a href="https://blog.teknkl.com/marketo-redirector-page-redux/" rel="noreferrer">my code for Marketo</a>. He’d seen a bunch of attempts in the HubSpot community, but none of them worked. (They all either missed hits, created a security vulnerability, or used — <em>ugh </em>— a random delay.)</p><p>After a little experimentation, I figured out the equivalent code for HubSpot. Download <strong><em>HubSpot HitEvent v1.3.1 </em></strong>from here and host it on your server:</p><div class="kg-card kg-file-card"><a class="kg-file-card-container" href="https://blog.teknkl.com/content/files/2026/02/teknkl-hubspothitevent-v1.3.1.js" title="Download" download=""><div class="kg-file-card-contents"><div class="kg-file-card-title">teknkl-hubspothitevent-v1.3.1</div><div class="kg-file-card-caption">JS shim to enable triggering on HubSpot hits</div><div class="kg-file-card-metadata"><div class="kg-file-card-filename">teknkl-hubspothitevent-v1.3.1.js</div><div class="kg-file-card-filesize">2 KB</div></div></div><div class="kg-file-card-icon"><svg viewBox="0 0 24 24"><defs><style>.a{fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.5px;}</style></defs><title>download-circle</title><polyline class="a" points="8.25 14.25 12 18 15.75 14.25"></polyline><line class="a" x1="12" y1="6.75" x2="12" y2="18"></line><circle class="a" cx="12" cy="12" r="11.25"></circle></svg></div></a></div><p>Create an otherwise blank LP and include the JS library directly after the standard HubSpot code:</p><pre><code class="language-markup">&lt;!-- Start of HubSpot Embed Code --&gt;
&lt;script type="text/javascript" id="hs-script-loader" async defer src="//js.hs-scripts.com/5483703.js"&gt;&lt;/script&gt;
&lt;!-- End of HubSpot Embed Code --&gt;
&lt;!-- TEKNKL HubSpot HitEvent addin --&gt; 
&lt;script src="https://your.example.com/teknkl-hubspothitevent-v1.3.1.js"&gt;&lt;/script&gt;
&lt;!-- TEKNKL HubSpot HitEvent addin --&gt; </code></pre><p>Then set up your allowed origins (i.e. asset hosting domains, to not be an open proxy) and the redirect code:</p><pre><code class="language-javascript">redirect: {
  const allowedOrigins = [ // which domains are allowed for redirection
    "https://pages.example.com",
    "https://www.example.com",
    "https://example.com"
  ]; 

  const errNoAsset = "Asset URL not found or bad encoding."; // message when no asset in hash
  const errInvalidAsset = "Asset URL not allowed."; // when asset not our domain
  const redirectMs = 2000;

  let assetURL;
  try {
    assetURL = new URL(decodeURIComponent(document.location.hash.substring(1)));
  } catch(e){
    document.body.append(errNoAsset);
    break redirect;      
  }

  if( !allowedOrigins.includes(assetURL.origin) ){
    document.body.append(errInvalidAsset);
    break redirect;
  }

  enableHubspotHitEvent({ forceAfter: redirectMs, debug: true });
  
  document.addEventListener("hubspot.trackPageView", function (e) {
    document.body.append(`Redirecting to &lt;${assetURL.href}&gt;.`);
    document.location.href = assetURL.href;
  });

}</code></pre><p>For simplicity, I used the same approach as the Marketo LP. <code>allowedOrigins</code> is an array of origins, <code>errNoAsset</code> and <code>errInvalidAsset</code> are error messages, <code>redirectMs</code> the fallback timeout (timeout is used <em>only </em>if the HubSpot JS is blocked).</p><p>At its simplest, build a Redirector LP URL like so:</p><pre><code class="language-markup">https://redirectorpage.example.com#https://www.example.com/asset.pdf</code></pre><p>But you should percent-encode the asset URL to not lose anything in edge cases:<a href="#notes" rel="noreferrer">[1]</a></p><pre><code class="language-markup">https://redirectorpage.example.com#https%3A%2F%2Fwww.example.com%2Fasset.pdf</code></pre><h5 id="notes">Notes</h5>
<p>[1] If the asset URL already has reserved characters that needed encoding, you don’t want to lose that. Like if the bare asset URL is:</p><pre><code class="language-markup">https://www.example.com/assets/?asset=me%26mrjones.pdf</code></pre><p>You need that <code>%26</code> encoded to <code>%2526</code> in the Redirector LP URL. Obviously best to avoid such situations, but they’re always lurking.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ You can’t get REST API Folder IDs from the Marketo Engage UI ]]></title>
        <description><![CDATA[ Don’t be tempted by ID-like stuff in the browser’s location bar. You gotta use the API to use the API. ]]></description>
        <link>https://blog.teknkl.com/you-cant-get-rest-api-folder-ids-from-the-marketo-engage-ui/</link>
        <guid isPermaLink="false">697d94b27f3af80001293868</guid>
        <category><![CDATA[ folders and programs ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Sun, 15 Feb 2026 01:59:00 -0500</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/01/marketo_quirks.png" medium="image"/>
        <content:encoded><![CDATA[ <p>The <strong>tl;dr: </strong>gotta use the API to use the API.</p><p>When browsing Marketing Activities, you’ll see the URL change to reflect Program IDs, Static List IDs, and Smart Campaign IDs:</p><pre><code class="language-markup">.../marketo-engage/classic/PG3350A1</code></pre><pre><code class="language-markup">.../marketo-engage/classic/ST6331A1LA1</code></pre><pre><code class="language-markup">.../marketo-engage/classic/SC9850A1ZN19</code></pre><p>In these cases, <mark>the first sequence of contiguous digits in the last path segment</mark> is the asset ID and can be used in the API. Above, you see Program ID <code>3350</code>, Static List ID <code>6331</code>, and Smart Campaign <code>9850</code>. The structure works for Smart Lists as well. Other asset types like Forms and Emails also have their IDs in the URL.</p><h2 id="not-campaign-folders-though">Not Campaign Folders, though!</h2><p>Selecting a Campaign Folder shows a <em>tempting </em>URL structure — </p><pre><code>.../marketo-engage/classic/MF2421A1</code></pre><p>— leading you to think the Folder ID is <code>2421</code>. Nope, in this case <code>2421</code> is the “tree node” ID, which is a UI thing only. It’s <mark><em>not </em>the same as the REST API Folder ID,</mark> which reflects the persistent structure in the database.</p><p>Worse, <mark>the <em>actual </em>Folder ID <code>2421</code> can be located somewhere else in the database</mark> (it’s also possible for there to be no true Folder ID with that value). So if you make a destructive change to Folder ID <code>2421</code> using the API — deleting tokens, for example, or blindly deleting all its children and then deleting the folder itself! — you’ve hit the wrong target entirely. Yikes.</p><p>The solution is to use the <a href="https://experienceleague.adobe.com/en/docs/marketo-developer/marketo/rest/assets/folders?ref=blog.teknkl.com" rel="noreferrer">Folders API endpoints</a> to retrieve Folder IDs. An extra step if you’re just doing one-off tasks using Postman, but all in all it’s better to learn how to use those APIs (in particular, the difference between a <code>Program</code>-type Folder and a <code>Folder</code>-type Folder).</p><h2 id="also-some-folders-don%E2%80%99t-even-have-tree-node-ids">Also, some folders don’t even <em>have </em>tree node IDs</h2><p>As you open/close <em>folders inside Programs </em>(not Campaign Folders) and folders in the Database section, you’ll notice the URL doesn’t change. Those types of folder are more “virtual”: for example, they don’t support tokens and you can’t focus them in the UI. They still have Folder IDs, of course, which you can get from the Folders API.  </p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ HOWTO: Make Marketo form onValidate/onSubmit/onSuccess idempotent, like native DOM events (plus learn what “idempotent” means!) ]]></title>
        <description><![CDATA[ Marketo form events don’t work like standard DOM events. (Thinking they do gets you callbacks that either never fire or wildly over-fire.) But with some tweaks, you can make MktoForms2 more familiar. ]]></description>
        <link>https://blog.teknkl.com/howto-make-marketo-forms-onvalidate-onsubmit-onsuccess-idempotent-like-native-dom-events-plus-learn-what-idempotent-means/</link>
        <guid isPermaLink="false">6988338fae6c5b0001fb552b</guid>
        <category><![CDATA[ idempotence ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Sat, 14 Feb 2026 15:11:07 -0500</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/02/formsjs.png" medium="image"/>
        <content:encoded><![CDATA[ <p>Marketo’s <code>onValidate</code> and friends <em>stack </em>like DOM event listeners: they run in the order they were added. But there are still major differences:</p><ol><li>In Marketo, <mark>the listener stack isn’t deduped.</mark> If you add the same listener function 3 times, <mark>it’ll run 3 times</mark>. In contrast, DOM <code>addEventListener(event,callback)</code> checks if the <code>callback</code> was already added and if so, doesn’t add it again.</li><li>In Marketo, <mark>all listeners always run</mark>. There’s no short-circuit method like <a href="https://developer.mozilla.org/en-US/docs/Web/API/Event/stopImmediatePropagation?ref=blog.teknkl.com" rel="noreferrer"><code>stopImmediatePropagation()</code></a>.</li><li>In Marketo, <mark>to stop submission you call <code>submittable()</code></mark>. There’s no event-level <a href="https://developer.mozilla.org/en-US/docs/Web/API/Event/preventDefault?ref=blog.teknkl.com" rel="noreferrer"><code>preventDefault()</code></a>. (Note <code>onValidate</code> — but <em>only </em><code>onValidate</code> — respects the callback’s return value, kind of like DOM inline <a href="https://html.spec.whatwg.org/multipage/webappapis.html?ref=blog.teknkl.com#:~:text=The%20return%20value%20of%20the%20function%20affects%20whether%20the%20event%20is%20canceled" rel="noreferrer">event handlers</a>.) </li></ol><p>Adding (1) is an easy tweak that makes it a lot easier to manage advanced form behaviors with lots of coordinated components.</p><p>Adding (2) is quite tricky<a href="#notes" rel="noreferrer">[1]</a> and I’ll cover that another day. Might even throw in (3).😏</p><h2 id="we%E2%80%99re-adding-idempotence-to-the-forms-js-api">We’re adding <em>idempotence </em>to the Forms JS API</h2><p><a href="https://en.wikipedia.org/wiki/Idempotence?ref=blog.teknkl.com#Computer_science_meaning" rel="noreferrer"><strong>Idempotence</strong></a><strong> </strong>is a show-offy CS term for <strong><em>an action that produces the same state, no matter how many times it runs.</em></strong><a href="#notes" rel="noreferrer">[2]</a><strong><em> </em></strong>DOM <code>addEventListener</code> is a great example. You can pass the same callback to <code>addEventListener</code> 1000x and the stack will be the same as if you only passed it once. </p><p>Here’s a built-in example of idempotence in Marketo Engage: running <em>Change Program Status </em>even if a lead already has that status. Marketo won’t stop you from running the action, it’ll just log the skip.</p><p>Another example: loading the Forms 2.0 or Munchkin library multiple times. Only the first load will create the global <code>MktoForms2</code> or <code>Munchkin</code> while subsequent loads will be ignored.<a href="#notes" rel="noreferrer">[3]</a>   </p><p>Anyway, wherever it’s feasible, <mark>idempotence makes code harder to break + easier to test</mark>.</p><h2 id="choosing-our-approach">Choosing our approach</h2><p>First, note there’s no public access to the listener stack. (Same goes for DOM listeners, of course — you can’t access the stack of <code>click</code> or <code>focus</code> listeners from JS.)</p><p>That means we need to <em>intercept </em>calls to the <code>on*</code> and <code>off*</code> functions to maintain our own <em>mirror </em>of the current stack. In every <code>on*</code> call, check the mirror to see if if a callback is already present and ignore the call if so. In every <code>off*</code> call, delete the callback from our mirror and then process the call normally.</p><h2 id="get-the-code">Get the code</h2><p>If nothing else, this serves as a showcase for two JavaScript features, <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set?ref=blog.teknkl.com" rel="noreferrer">Set</a> and <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy?ref=blog.teknkl.com" rel="noreferrer">Proxy</a>. Those keep our code pretty short and sweet: </p><pre><code class="language-javascript">/**
 * Enable DOM-like idempotent event stack for Marketo forms 
 * @author Sanford Whiteman
 * @version v1.0.0 2026-02-03
 * @copyright © 2026 Sanford Whiteman
 * @license MIT: This license must appear with all reproductions of this software.
 *
 */
function enableIdempotentListeners(readyForm){

   const events = ["Validate", "Submit", "Success"];
   const handlerPairs = events.map( event =&gt; ["on" + event, "off" + event] );
      
   handlerPairs.forEach( ([onHandler, offHandler]) =&gt; {
      
      const idempotentStack = new Set();
       
      readyForm[onHandler] = new Proxy(readyForm[onHandler], {
         apply(originalOnHandler, thisArg, [callback]){

            if( !idempotentStack.has(callback) ) {
               Reflect.apply(originalOnHandler, thisArg, [callback]);     
               idempotentStack.add(callback);
            }
            
            return readyForm;
         }
      });

      readyForm[offHandler] = new Proxy(readyForm[offHandler], {
         apply(originalOffHandler, thisArg, [callback]){
           Reflect.apply(originalOffHandler, thisArg, [callback]);   
           idempotentStack.delete(callback);

           return readyForm;
         }
      });
      
   });
}</code></pre><p>In the distant past, we would’ve done this with Array <code>indexOf()</code> and storing the original handlers in a closure, but it’s fun to use later-model stuff.</p><h2 id="run-enableidempotentlisteners-on-form-load">Run <code>enableIdempotentListeners</code> on form load</h2><p>Before <code>enableIdempotentListeners</code> starts intercepting, <code>onValidate</code>/<code>onSubmit</code>/<code>onSuccess</code> will work as they usually do. So ideally you start intercepting immediately by using <code>onReady</code>, i.e. the 4th argument to <code>loadForm()</code>:</p><pre><code class="language-javascript ">MktoForms2.loadForm("//123-ABC-456.mktoweb.com", "123-ABC-456", 999, enableIdempotentListeners);</code></pre><p>If you absolutely can’t use <code>onReady</code>, you can use <code>whenReady()</code> right<em> </em>after loading the forms library:</p><pre><code class="language-javascript">MktoForms2.whenReady(enableIdempotentListeners);</code></pre><h2 id="enjoy-your-newfound-idempotence">Enjoy your newfound idempotence</h2><p>Now, even if you add the same callback under multiple conditions, it’ll only run once:</p><pre><code>function validateSpecial(nativeValid){
  if( !nativeValid ) return;
  console.log("something’s gonna be done for form", mktoForm.getId());
}

MktoForms2.whenReady(function(readyForm){
  if( someCondition ) {
    readyForm.onValidate(validateSpecial);
  }
  
  /* ... do lots and lots of other stuff... */

  if( someOtherCondition ) {
    // thanks to idempotence, this won’t add the callback again
    readyForm.onValidate(validateSpecial);
  }
});</code></pre><h5 id="notes">Notes</h5>
<p>[1] You might think, <strong><em>“Just use a variable in a shared scope,”</em> </strong>e.g. check if <code>skipFurtherValidation === true</code> at the top of each listener function. That’ll work the <em>first </em>time through the stack, sure! But remember, there’s no <em>event object </em>passed to Marketo listeners.</p><p>(By contrast, with DOM <code>submit</code> events, a new <code>SubmitEvent</code> is created <em>on each attempt </em>and passed to each <code>submit</code> listener. One click ⇒ one distinct <code>SubmitEvent</code>.)</p><p>So consider someone trying to submit a Marketo form after correcting an initial error. How do you know if this is a new submit attempt, so <code>skipFurtherValidation</code> should be reset, rather than reading its last value? 😜</p><p>[2] Some people misdefine an idempotent action as <em>an action that throws an error if it’s already run</em>. But that’s not it. Idempotence means you can re-run <mark><em>without </em>an error yet without further changes</mark>.</p><p>Others misdefine it as <em>an action that never makes changes,<strong> </strong></em>like a database query. But that’s not it, either. A read-only query is implicitly idempotent, yes. But an update or insert can also be idempotent.</p><p>[3] When loading forms from different Marketo instances, this optimization becomes suboptimal and I’ve written before about how to disable it.  </p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Why the back/forward cache (bfcache) loses Munchkin activities, and how to fix it ]]></title>
        <description><![CDATA[ Getting web analytics 100% in sync between Munchkin and GA4/Clicky/etc. is still a pipe dream, but here’s one reason they differ. ]]></description>
        <link>https://blog.teknkl.com/why-the-back-forward-cache-bfcache-loses-munchkin-activities-and-how-to-fix-it/</link>
        <guid isPermaLink="false">6965c71effb0b400018e7fcd</guid>
        <category><![CDATA[ Munchkin ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Fri, 23 Jan 2026 21:27:00 -0500</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/01/munchkin.png" medium="image"/>
        <content:encoded><![CDATA[ <p>Modern browsers use the <a href="https://web.dev/articles/bfcache?ref=blog.teknkl.com" rel="noreferrer"><em>back/forward cache</em></a><em> </em>(“bfcache”) to accelerate simple <em>⇦</em> and <em>⇨</em> navigation. Confusingly, the <mark>bfcache is different from both the classic </mark><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Caching?ref=blog.teknkl.com" rel="noreferrer"><mark>HTTP cache</mark></a><mark> and the new </mark><a href="https://developer.mozilla.org/en-US/docs/Web/API/Cache?ref=blog.teknkl.com" rel="noreferrer"><mark>Cache API</mark></a><mark>,</mark> and its impact on 3rd-party JS isn’t widely measured.</p><p>The bfcache is dynamically managed and cleaned up by the browser: it doesn’t have user-tweakable settings and can’t be directly turned off. There are also <a href="https://developer.mozilla.org/en-US/docs/Web/API/Performance_API/Monitoring_bfcache_blocking_reasons?ref=blog.teknkl.com#blocking_reasons" rel="noreferrer">lots of different reasons</a> that <em>the same page </em>might be bfcache-able on one visit but not on the next. </p><p>This all gives the bfcache a black-box vibe. But the bfcache <em>usually </em>leads only to Good Things™ so people don’t worry about it, aside from some devs trying to make sites as bfcache-able as possible.</p><p>Interestingly, Munchkin’s backward compatibility (pageview and link tracking still work with old browsers) conflicts with the bfcache design. This wasn’t deliberate, of course, but the product of different goals.</p><p>In this recording, check out when <code>visitWebPage</code> and <code>clickLink</code> events <em>stop </em>being sent to Marketo:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/01/Animation9.webp" class="kg-image" alt="" loading="lazy" width="1647" height="857" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2026/01/Animation9.webp 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2026/01/Animation9.webp 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1600/2026/01/Animation9.webp 1600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/01/Animation9.webp 1647w" sizes="(min-width: 720px) 720px"></figure><p>The initial <code>visitWebPage</code> fires, as do <code>clickLink</code> events for the top 2 internal links. When I click the 3rd off-page link (“This is a link to another page”) there’s a <code>clickLink</code> for that, too. Then I use the back button to return to the initial page. Upon returning, <em>no events are fired, </em>though I’m interacting with the same links.</p><h2 id="munchkin-unhooks-click-events-out-of-an-abundance-of-caution">Munchkin unhooks <code>click</code> events out of an abundance of caution</h2><p>See, when Munchkin detects the page is about to unload, it <mark><em>unhooks itself </em>(i.e. removes event listeners) from all interesting <code>&lt;a&gt;</code> elements and from the main <code>document</code></mark> to avoid potential memory leaks. Using the <a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event?ref=blog.teknkl.com" rel="noreferrer"><code>beforeunload</code></a> or <a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/unload_event?ref=blog.teknkl.com" rel="noreferrer"><code>unload</code></a> event to free memory wasn’t controversial in the past. While the majority of browsers — even in those old days — <em>didn’t </em>have problems collecting memory, <a href="https://www.reigndropsfall.net/2011/01/05/internet-explorer-event-handler-leaks/?ref=blog.teknkl.com#detach-on-unload" rel="noreferrer">a few did</a>, and there were no downsides.</p><p>Then came the bfcache. The bfcache changes what it means to “unload” a document. Now, unload can mean <em>either:</em></p><ol><li>the document is going away <em>forever </em>and its current state (DOM/JS/memory) is ready to be garbage collected (the old meaning); <em>or</em></li><li>the document will be <mark>restored from a snapshot of its current state</mark> and resume running if a back/forward action — on-screen button, mouse button, or touch gesture — happens within a short time window (<em>after </em>that window, the document is removed from the bfcache and behaves as in (1))</li></ol><p>It’s case (2) you’re seeing above. Munchkin, worried about memory leaks, removes all DOM listeners before the page unloads. As the page is bfcache-able, the browser caches its final state — that is, <em>without the listeners</em>. A back action is used quickly, so the bfcache’d state is restored rather than loading the page over the network.</p><h2 id="the-fix">The fix</h2><p>The bfcache makes eligible pages available <em>instantly: </em>it’s more like showing a hidden page than “loading” a document in the traditional sense. Indeed, the event that can tell us if the document was restored from bfcache is called <a href="https://developer.mozilla.org/en-US/docs/Web/API/PageTransitionEvent?ref=blog.teknkl.com" rel="noreferrer"><code>pageshow</code></a>.<a href="#notes" rel="noreferrer">[1]</a></p><p>If a <code>pageshow</code> event’s <code>persisted</code> property is <code>true</code>, the page was restored. So we:</p><ol><li>check for <code>persisted</code></li><li>remove previous evidence of Munchkin</li><li>reinject the Munchkin bootstrap (which loads the library)</li></ol><p>Simple stuff:  </p><pre><code class="language-javascript">window.addEventListener("pageshow", function(e){
   if(e.persisted){
      delete window.Munchkin;
      delete window.MunchkinTracker;
      
      const munchkinBootstrap = document.createElement("script");
      munchkinBootstrap.src = "//munchkin.marketo.net/munchkin-beta.js";
      
      const munchkinInit = {
        handleEvent(e){
          Munchkin.init("123-ABC-456"); // plus Munchkin options
        }
      };

      munchkinBootstrap.addEventListener("load", munchkinInit, { once: true });
      document.head.append(munchkinBootstrap);
   }
});</code></pre><p>Now,  a quick back-and-forth doesn’t lose any hits:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/01/Animation10.webp" class="kg-image" alt="" loading="lazy" width="1647" height="971" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2026/01/Animation10.webp 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2026/01/Animation10.webp 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1600/2026/01/Animation10.webp 1600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/01/Animation10.webp 1647w" sizes="(min-width: 720px) 720px"></figure><h2 id="more-on-modernizing-munchkin">More on modernizing Munchkin</h2><p>Also wrote recently about <a href="https://blog.teknkl.com/streamline-the-munchkin-embed-code-taking-out-unnecessary-cruft/" rel="noreferrer">trimming down the Munchkin embed code</a> if, like most people, you don’t care about Internet Explorer anymore. </p><h5 id="notes">Notes</h5>
<p>[1] Familiar events like <code>DOMContentLoaded</code> and window <code>load</code> don’t fire <em>again</em> when bfcache is used, which makes sense — it would be catastrophic to re-fire those events on a page that was merely temporarily hidden.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Properly preserving Marketo {{dotted.tokens}} in URL builders ]]></title>
        <description><![CDATA[ Way too many URL builders are naïve about how tokens work. (Not just Marketo tokens, but other platforms’ variables/tokens/merge fields as well.) ]]></description>
        <link>https://blog.teknkl.com/properly-preserving-marketo-dotted-tokens-in-url-builders/</link>
        <guid isPermaLink="false">68f9a1767c18450001f536b1</guid>
        <category><![CDATA[ URLs and URIs ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Thu, 22 Jan 2026 23:56:19 -0500</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/10/formsjs-2.png" medium="image"/>
        <content:encoded><![CDATA[ <p>Busy MOps teams use a <em>URL builder </em>to manage tracking params (and sometimes for add-to-calendar URLs and other areas). They have a UI like this:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/10/image-11.png" class="kg-image" alt="" loading="lazy" width="1098" height="762" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/10/image-11.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/10/image-11.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/10/image-11.png 1098w" sizes="(min-width: 720px) 720px"></figure><p>And generate a copy-and-pasteable URL like this:</p><pre><code class="language-markup">https://www.example.com/landingpage?baseparam1=value1&amp;utm_campaign=nosferatrix-or-treat-2025&amp;utm_medium=email&amp;utm_source=marketo&amp;utm_content=logo&amp;utm_term=default</code></pre><p>Sometimes they’re built using Excel or a Google Sheet (ugh) instead of a separate web app, but the idea is the same. </p><p>Problem: most such builders have <em>at least one </em>fatal bug. <mark>They do not properly support Marketo <code>{{dotted.token}}</code> syntax in query param values</mark>.  This is bad, since tokens are a huge operational benefit on their own. Let’s say you want to use the built-in <code>{{campaign.Name}}</code> as the <code>utm_campaign</code> value:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/10/image-10.png" class="kg-image" alt="" loading="lazy" width="1074" height="783" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/10/image-10.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/10/image-10.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/10/image-10.png 1074w" sizes="(min-width: 720px) 720px"></figure><p>A naïve URL builder will mistakenly think <code>{{campaign.Name}}</code> is a <em>literal value, </em>as opposed to an <em>interpolated variable, </em>and will URL-encode it, breaking the URL in Marketo:</p><pre><code class="language-markup">https://www.example.com/landingpage?baseparam1=value1&amp;utm_campaign=%7B%7Bcampaign.Name%7D%7D&amp;utm_medium=email&amp;utm_source=marketo&amp;utm_content=logo&amp;utm_term=default</code></pre><p>To be clear, <mark>URL-encoding literal values is absolutely correct, and a builder that doesn’t even do <em>that </em>is useless!</mark> </p><p>The problem is not understanding that <mark>some patterns must be left unencoded</mark>, so Marketo can replace them with the underlying value — which <em>you must </em>ensure is a URL-safe value on the Marketo side.<a href="#notes" rel="noreferrer">[1]</a></p><h2 id="terminus-almost-gets-it">Terminus <em>almost </em>gets it</h2><p>The screenshots in this post are from <a href="https://terminusapp.com/?ref=blog.teknkl.com" rel="noreferrer">Terminus</a>, which URL-encodes and makes a good faith <em>attempt </em>at token support — even if it doesn’t succeed, as we’ll see below. (As one contrast, <a href="https://trackfunnels.com/utm-links-builder/?ref=blog.teknkl.com" rel="noreferrer">this builder</a> from TrackFunnels doesn’t encode at all and is basically fraudulent!)</p><p>In Terminus, you can select “preserved characters” which won’t be encoded:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/10/image-5.png" class="kg-image" alt="" loading="lazy" width="1152" height="222" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/10/image-5.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/10/image-5.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/10/image-5.png 1152w" sizes="(min-width: 720px) 720px"></figure><p>Looks promising. Except with Marketo <code>{{dotted.token}}</code>  syntax, <mark>characters <em>inside the double brackets </em>must not be encoded</mark>, either<em>. </em>Enter tokens with spaces in the Terminus builder:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/10/image-8.png" class="kg-image" alt="" loading="lazy" width="1074" height="771" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/10/image-8.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/10/image-8.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/10/image-8.png 1074w" sizes="(min-width: 720px) 720px"></figure><p>And it’ll output those as <code>{{my.Public%20Campaign%20Name}}</code> and <code>{{lead.Marketo%20Unique%20Code}}</code> respectively. Which means Marketo will no longer substitute the token values; instead, it’ll output <em>nothing, </em>as it always does when a token isn’t recognized. Oops!</p><h2 id="covering-both-bases">Covering both bases </h2><p>A proper URL builder, like <a href="https://codepen.io/figureone/pen/zxBdWvV?editors=1010&ref=blog.teknkl.com" rel="noreferrer">the one I built specifically for this post</a>, uses a regex to de-encode Marketo tokens:</p><pre><code class="language-javascript">encodedURL.replaceAll( /(%7B%7B.+?\..+?%7D%7D)/ig, decodeURIComponent )</code></pre><p>The regex restores sequences of <em>encoded <code>{{</code> + <code>dotted.string</code> + encoded <code>}}</code></em> to the original unencoded value. </p><h5 id="notes">Notes</h5>
<p>[1] Yes, that’s right. Marketo <em>will not automatically URL-encode </em><code>{{my.tokens}}</code>, <code>{{campaign.tokens}}</code> or <code>{{lead.tokens}}</code><em>. </em>You must make sure that either:</p><ol><li>all values are URL-safe to start, or</li><li>all values are URL-encoded using Velocity before sending</li></ol><p>Achieving (1) is impossible if you use <code>{{campaign.tokens}}</code> or <code>{{program.tokens}}</code>, since you can’t truly stop users from introducing non-URL-safe characters via the Marketo UI. That’s the main reason I prefer a custom <code>{{my.Public Campaign Name}}</code> token over <code>{{campaign.name}}</code>. (The other reason being internal name privacy: <mark>you don’t want people knowing they’re in “Insufferable Fools Group 2”</mark>.) </p><p>For example, say the campaign name in Marketo is <code>2025-12-04 Weekly Q &amp; A</code>. In this case, <code>{{campaign.Name}}</code> will break URLs because of the <code>&amp;</code>. A separate <code>{{my.Public Campaign Name}}</code> lets you encode the value, i.e. <code>2025-12-04%20Weekly%20Q%20%26%20A</code>, or use a custom URL-safe variant like <code>2025-12-04-wekly-q-and-a</code>.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Streamline the Munchkin embed code, taking out outdated cruft ]]></title>
        <description><![CDATA[ No pressing need to do this, but it can help you &amp; your site feel more up-to-date. ]]></description>
        <link>https://blog.teknkl.com/streamline-the-munchkin-embed-code-taking-out-unnecessary-cruft/</link>
        <guid isPermaLink="false">696dac21bb9ea50001932a53</guid>
        <category><![CDATA[ Munchkin ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Sun, 18 Jan 2026 23:28:10 -0500</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/01/munchkin-1.png" medium="image"/>
        <content:encoded><![CDATA[ <p>In my <a href="https://blog.teknkl.com/why-the-back-forward-cache-bfcache-loses-munchkin-activities-and-how-to-fix-it/" rel="noreferrer">last post on the bfcache</a> I mentioned the Munchkin library does some backward-compatible stuff that’s not necessary in modern browsers (it was only relevant for old versions of Internet Explorer).</p><p>In fact, the Munchkin <em>embed code </em>is also needlessly verbose. All you need to be compatible with the latest Chrome/Edge, Firefox, and Safari, on desktop and mobile, is this:</p><pre><code class="language-javascript">{
  const munchkinBootstrap = document.createElement("script");
  munchkinBootstrap.src = "//munchkin.marketo.net/munchkin-beta.js";

  munchkinBootstrap.addEventListener("load", function(e) {
    Munchkin.init("123-ABC-456"); // plus your custom Munchkin options, if any
  }, 
  { 
    once: true 
  });
  document.head.append(munchkinBootstrap);
};</code></pre><p>(That could also have line breaks removed, but I wanted it to be readable.)</p><p>Contrast this with the default “async embed code” in the Marketo UI:</p><pre><code class="language-javascript">var didInit = false;
function initMunchkin() {
  if(didInit === false) {
    didInit = true;
    Munchkin.init("123-ABC-456"); // plus your custom Munchkin options, if any
  }
}
var s = document.createElement('script');
s.type = 'text/javascript';
s.async = true;
s.src = '//munchkin.marketo.net/munchkin-beta.js';
s.onreadystatechange = function() {
  if (this.readyState == 'complete' || this.readyState == 'loaded') {
    initMunchkin();
  }
};
s.onload = initMunchkin;
document.getElementsByTagName('head')[0].appendChild(s);</code></pre><p>My changes were:</p><ul><li>using block scope instead of a function</li><li>switching <code>var</code> to <code>const</code></li><li>running the event handler <code>once</code> instead of the <code>didInit</code> flag (there are cases where you still need a separate flag, but not here)</li><li>removing explicit <code>type</code> and <code>async</code> (those are the HTML5 defaults)</li><li>assuming there’s only one <code>&lt;head&gt;</code> (let’s be serious, if you have duplicate <code>&lt;head&gt;</code> tags, things <em>should </em>break)</li><li>removing <code>onreadystatechange</code>, which was <a href="https://humanwhocodes.com/blog/2009/06/23/loading-javascript-without-blocking/?ref=blog.teknkl.com#:~:text=The%20real%20problem%20is%20in%20Internet%20Explorer" rel="noreferrer">only fired in IE</a></li></ul><p>If you want your site to look new-ish to those peeking under the hood, use the newer snippet. Doesn’t change how Munchkin <em>works,</em> just how it’s loaded.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Parse “Visit Web Page” {{trigger.tokens}} into individual query params using FlowBoost ]]></title>
        <description><![CDATA[ Together, the {{trigger.Web Page}} and {{trigger.Query Parameters}} tokens can recreate a visited URL. But parsing the URL into separate fields can’t be done natively: you need FlowBoost. ]]></description>
        <link>https://blog.teknkl.com/parse-visit-web-page-trigger-tokens-into-individual-query-params-using-flowboost/</link>
        <guid isPermaLink="false">6973fe3e008ab90001cba2b3</guid>
        <category><![CDATA[ Munchkin ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Sat, 17 Jan 2026 18:43:00 -0500</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/01/flowboostjs_new.png" medium="image"/>
        <content:encoded><![CDATA[ <p>Marketo uses offbeat definitions when it comes to web visits. For example, if you visit this URL in your browser: </p><pre><code class="language-markup">https://example.com/page/4?utm_campaign=2026-01-sample-sale&amp;utm_medium=email&amp;utm_source=marketo#fragmento</code></pre><p>Munchkin insists the “web page” (<code>{{trigger.Web Page}}</code>) is:</p><pre><code class="language-markup">example.com/page/4#fragmento</code></pre><p>That is, it’s <mark>only the host + path + hash parts of the URL</mark>. Not the protocol or the query string, which is weird.</p><p>The query string didn’t even have an associated token for a long time (it was therefore unusable in webhooks/flow steps/emails) but now we have a separate <code>{{trigger.Query Parameters}}</code>:</p><pre><code class="language-markup">utm_campaign=2026-01-sample-sale&amp;utm_medium=email&amp;utm_source=marketo</code></pre><p>Nice, but there’s still no query string <em>parser </em>on the Marketo back end. You can’t get individual params (<code>utm_campaign</code>, <code>utm_medium</code>, and <code>utm_source</code>) and write them to their own fields<em>. </em>Also, when filtering, you’re forced to do pretty fragile [contains] matching on the whole query string.</p><p>FlowBoost has a standard <a href="https://developer.mozilla.org/en-US/docs/Web/API/URL/URL?ref=blog.teknkl.com" rel="noreferrer">URL</a> parser, <code>FBHttp.URL</code> (same as <code>URL</code> in browsers) which can unweird the weirdness. You just need to feed the URL constructor correctly:</p><ol><li>prepend a protocol — we assume <code>https:</code> these days — to the <code>{{trigger.Web Page}}</code> (host + path + hash)</li><li>construct a URL from the protocol + host + path + hash </li><li>set the URL’s <code>search</code> property to <code>{{trigger.Query Parameters}}</code>, which is automatically parsed into name-value pairs</li></ol><p>The FlowBoost payload:</p><pre><code class="language-javascript">const vwpContext = {
  Protocol : "https:",
  HostPathHash: {{trigger.Web Page}},
  Search: {{trigger.Query Parameters}}
};

const url = new FBHttp.URL(vwpContext.Protocol + "//" + vwpContext.HostPathHash);
url.search =  vwpContext.Search;

hostPath = url.origin + url.pathname;
origin = url.origin;
path = url.pathname;
hostname = url.hostname;
hash = url.hash.substring(1);
fullQuery = url.searchParams.toString();

queryParams = {};
for( const [name,value] of url.searchParams ){
  queryParams[name] = value;
}</code></pre><p>The webhook response has lots of choices ready for <em>Response Mappings:</em>  </p><pre><code class="language-javascript">{
  "fullHref": "https://example.com/page/4?utm_campaign=2026-01-sample-sale&amp;utm_medium=email&amp;utm_source=marketo#fragmento",
  "hostPath": "https://example.com/page/4",
  "origin": "https://example.com",
  "path": "/page/4",
  "hostname": "example.com",
  "hash": "fragmento",
  "fullQuery": "utm_campaign=2026-01-sample-sale&amp;utm_medium=email&amp;utm_source=marketo",
  "queryParams": {
    "utm_campaign": "2026-01-sample-sale",
    "utm_medium": "email",
    "utm_source": "marketo"
  }
}</code></pre><p>For example, map <code>queryParams.utm_campaign</code> to a custom field <em>Last utm_campaign.</em></p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Be aware of decodeURIComponent() limitations (not only with Marketo forms, but with any 1ˢᵗ or 3ʳᵈ party JS) ]]></title>
        <description><![CDATA[ You’re just one unencoded (or wrongly-encoded) query param away from a broken form. Scary, right? ]]></description>
        <link>https://blog.teknkl.com/be-aware-of-decodeuricomponent-limitations-with-any-js-not-just-marketo-forms/</link>
        <guid isPermaLink="false">694e23d4652fa800013c598d</guid>
        <category><![CDATA[ URLs and URIs ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Sun, 04 Jan 2026 02:42:00 -0500</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/12/browserandurlstuff.png" medium="image"/>
        <content:encoded><![CDATA[ <p>Here’s a fun one. Open a URL that hosts a Marketo form and change the query string to <code>?utm_medium=email&amp;utm_campaign=new-years-discount-10%</code>.</p><p>Here’s how your form will look:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/12/image-11.png" class="kg-image" alt="image of blank page" loading="lazy" width="418" height="467"></figure><p><strong><em>What the?</em></strong> Let’s check the browser console: </p><pre><code class="language-console">⮾️ Uncaught URIError: URI malformed
    at decodeURIComponent (&lt;anonymous&gt;)
    at b.exports (forms2.min.js)
    at h.getPageFields (forms2.min.js)
    at yourpage.html?utm_medium=email&amp;utm_campaign=new-years-discount-10%
    at q (forms2.min.js)</code></pre><p>Hmm, that’s not great. Granted, we <mark>didn’t URL-encode the param names or values</mark>, like a certain blogger always reminds us to. Could that be it?</p><pre><code class="language-console">﹥ decodeURIComponent("utm_medium")
⋖ 'utm_medium'
﹥ decodeURIComponent("email")
⋖ 'email'
﹥ decodeURIComponent("utm_campaign")
⋖ 'utm_campaign'
﹥ decodeURIComponent("new-years-discount-10%")
⮾ Uncaught URIError: URI malformed
     at decodeURIComponent(&lt;anonymous&gt;)
     at &lt;anonymous&gt;</code></pre><p>Yep, that was it: <mark><code>decodeURIComponent</code> throws a </mark><a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/URIError?ref=blog.teknkl.com" rel="noreferrer"><code><mark>URIError</mark></code></a><mark> if a value contains invalid percent-encoding</mark>. Make no mistake, a <code>%</code> must be followed by 2 hex digits (<code>0-9</code> or <code>A-F</code>, case-insensitive) in a URL, per <a href="https://www.rfc-editor.org/rfc/rfc3986?ref=blog.teknkl.com#appendix-A" rel="noreferrer">RFC 3986</a>. That URL, as innocent + unambiguous as it looks, is invalid.</p><p>Marketo’s forms library — as do many others, we’re definitely not alone here! — still uses <code>decodeURIComponent</code> to parse query strings. Since it doesn’t wrap calls in <code>try/catch</code> or do any pre-fixup, a <code>URIError</code> is fatal.</p><p>Out of curiosity, let’s see what <code>URLSearchParams</code> does with the same query string:</p><pre><code class="language-console">﹥ const searchParams = new URLSearchParams(document.location.search);
﹥ searchParams.get("utm_campaign");
⋖ 'new-years-discount-10%'</code></pre><p>Interesting. <code>URLSearchParams</code> — the new(ish) native way to read and write query strings — is <em>forgiving </em>of the dangling <code>%</code> and doesn’t throw an error. It obeys <a href="https://en.wikipedia.org/wiki/Robustness_principle?ref=blog.teknkl.com" rel="noreferrer">Postel’s Law</a>, in other words, accepting lightly-broken input but not producing broken output.</p><h2 id="but-decodeuricomponent-isn%E2%80%99t-completely-strict-since-it-tolerates-unencoded-input">But <code>decodeURIComponent</code> isn’t completely strict, since it tolerates <em>unencoded </em>input</h2><p>It’s tempting to conclude <code>decodeURIComponent</code> only supports output that would be generated by its companion function <code>encodeURIComponent</code>, but that’s not so. Check this out:</p><pre><code class="language-console">﹥ decodeURIComponent("😊")
⋖ '😊'</code></pre><p><code>decodeURIComponent</code> is cool with an unencoded emoji, even though <em>that’s also invalid in a URL </em>(it should be <code>%F0%9F%98%8A</code>)!</p><p>So it’s strict when input is <em>incorrectly percent-encoded: </em>it also fails on, say, trailing <code>%F</code> or <code>%AZ</code> anywhere in a string, since those aren’t <code>%</code> followed by 2 hex digits. But it’s loose when a value simply <em>isn’t percent-encoded. </em></p><p>Note <code>URLSearchParams</code> is consistently loose, also tolerating the unencoded emoji: </p><pre><code class="language-console">﹥ new URLSearchParams("test=😊").get("test")
⋖ '😊'</code></pre><h2 id="protect-decodeuricomponent-dependent-parsers-from-bad-percent-encoding">Protect <code>decodeURIComponent</code>-dependent parsers from bad percent-encoding</h2><p>Later, I’ll show how to modify a parser that uses <code>decodeURIComponent</code> so it’s more <code>URLSearchParams</code>-ishly tolerant of bad encoding (though you should migrate to <code>URLSearchParams</code> itself, it’s great).</p><p>But what if <mark>you didn’t write the parser, so you can’t modify it — and say you accidentally sent out a bad URL and 1000s of people are about to land on your LP</mark>? </p><p>Not to worry, you have 2 great options and 1... let’s say, <em>interesting </em>option. Choose one and include it as high as possible in <code>&lt;head&gt;</code>.</p><h3 id="option-1-proxy-decodeuricomponent-while-still-using-it-under-the-hood">Option 1: Proxy <code>decodeURIComponent</code> while still using it under the hood</h3><p>This option adds a one-line “shim” to replace invalid sequences with valid equivalents before calling native <code>decodeURIComponent</code>. In my tests, it’s lightning-fast (to the tune of 2 million calls/sec) so you don’t have to worry about overhead. It also <a href="#about-the-plus-sign-thing" rel="noreferrer">replaces the troublesome <code>+</code> sign</a> in the same shot, so you don’t need to repeat that step. </p><pre><code class="language-javascript">window.decodeURIComponent = new Proxy(window.decodeURIComponent,{
    apply(target, thisArg, [encoded]) {
        const dUCSafeEncoded = encoded.replace(/(\+|%(?![0-9a-f][0-9a-f]))/ig, (match) =&gt; match === "+" ? "%20" : "%25" );
        return Reflect.apply(target, thisArg, [dUCSafeEncoded])
    }
});</code></pre><h3 id="option-2-overwrite-decodeuricomponent-to-use-urlsearchparams-under-the-hood">Option 2: Overwrite <code>decodeURIComponent</code> to use <code>URLSearchParams</code> under the hood</h3><p>This one is provided more as a curiosity. It overwrites <code>decodeURIComponent</code> to use the <code>URLSearchParams</code> parser. Short and sweet-looking, for sure. But <code>URLSearchParams</code> is designed to parse a full query string at once, not to be called for <em>every component. </em>There’s considerable overhead in parsing and getting the first value from the iterator. (We’re still talking ~400,000 calls/sec, so you’re really unlikely to feel any difference in MOps applications, but I can’t fully recommend it.)</p><pre><code class="language-javascript">window.decodeURIComponent = function(encoded){
    const uSP = new URLSearchParams("=" + encoded);
    return uSP.values().next().value;
}</code></pre><h3 id="option-3-canonicalize-the-query-string-upfront">Option 3: Canonicalize the query string upfront</h3><p>This option leverages <code>URLSearchParams</code>’s loose input + strict output to <em>in-place replace </em>the query string (don’t worry, no refresh) with a properly encoded version. Any dangling <code>%</code> becomes <code>%25</code><a href="#notes" rel="noreferrer">[1]</a>, so native <code>decodeURIComponent</code> won’t have trouble.</p><pre><code class="language-javascript">const currentURL = new URL(document.location.href);
currentURL.search = currentURL.searchParams.toString();
history.replaceState({},"",currentURL.href);</code></pre><h2 id="fix-a-decodeuricomponent-dependent-parser">Fix a <code>decodeURIComponent</code>-dependent parser</h2><p>A <em>ton </em>of forms and analytics code still uses <code>decodeURIComponent</code>; again, Marketo users are not alone. Those query string parsers look like this:<a href="#notes" rel="noreferrer">[2]</a></p><pre><code class="language-javascript">const queryParams = document.location.search
    .substring(1)
    .replace(/\+/g, "%20")
    .split("&amp;")
    .reduce( (map, nextPair) =&gt; {
        const hasValue = nextPair.includes("=");
        const rawKey = hasValue ? nextPair.substring(0,nextPair.indexOf("=")) : nextPair,
              rawValue = hasValue ? nextPair.substring(nextPair.indexOf("=")+1) : "";
        const key = decodeURIComponent(rawKey),
              value = decodeURIComponent(rawValue);
        if( key ) {
          if( !map.has(key) ) { 
            map.set(key, value);
          } else if ( Array.isArray(map.get(key)) ) {
            map.get(key).push(value);
          } else {
            map.set(key, [map.get(key),value]);
          }
        }
        return map;
    }, new Map() );</code></pre><p>Straightforward enough, right? </p><ul><li>split query string on <code>&amp;</code> to separate params</li><li>split each param on the first <code>=</code> to separate names from values</li><li>set a single value or append to an array of values (remember, <code>?a=one&amp;a=two</code> is valid)</li></ul><h3 id="about-the-plus-sign-thing">About the plus sign thing</h3><p>Perhaps not self-explanatory is <mark>replacing <code>+</code> with encoded <code>%20</code>.</mark> This is necessary because <code>decodeURIComponent</code> supports <em>only </em>RFC 3986-strict percent encoding, while in the wild you’ll see both RFC 3986 and <code>x-www-form-urlencoded</code> strings.<a href="#notes" rel="noreferrer">[3]</a> The latter uses <a href="https://url.spec.whatwg.org/?ref=blog.teknkl.com#string-percent-encode-after-encoding" rel="noreferrer"><code>+</code> for spaces</a> because it’s more concise, creating 30 years of confusion and counting!</p><p>To adapt that code to be <code>URLSearchParams</code>-ish, also replace <code>%</code>, when <em>not </em>followed by 2 hex digits, with <code>%25</code>:</p><pre><code class="language-javascript"> const queryParams = document.location.search
    .substring(1)
    .replace(/(\+|%(?![0-9a-f][0-9a-f]))/ig, (match) =&gt; match === "+" ? "%20" : "%25" )
    .split("&amp;")
    .reduce( (map, nextPair) =&gt; {
        const hasValue = nextPair.includes("=");
        const rawKey = hasValue ? nextPair.substring(0,nextPair.indexOf("=")) : nextPair,
              rawValue = hasValue ? nextPair.substring(nextPair.indexOf("=")+1) : "";
        const key = decodeURIComponent(rawKey),
              value = decodeURIComponent(rawValue);
        if( key ) {
          if( !map.has(key) ) { 
            map.set(key, value);
          } else if ( Array.isArray(map.get(key)) ) {
            map.get(key).push(value);
          } else {
            map.set(key, [map.get(key),value]);
          }
        }
        return map;
    }, new Map() );</code></pre><h2 id="should-you-fix-it-though-%F0%9F%A4%94"><em>Should </em>you fix it, though? 🤔</h2><p><code>decodeURIComponent</code>’s strictness has an upside. Provided you catch errors during testing, it tells you something’s wrong with whoever/whatever is creating your URLs.</p><p>Hiding the problem with <code>new-years-discount-10%</code> encourages broken “URL builders” to stay broken in worse ways, like failing to encode literal <code>&amp;</code> as <code>%26 </code>(which will never throw a parsing error, it’ll just break reporting and/or on-page logic).</p><p>Might be worth enduring a post mortem if your team pays attention to encoding in the future.</p><h5 id="notes">Notes</h5>
<p>[1] You could debate whether bad encoding should result in <em>stripping </em>the <code>%</code> or <em>encoding </em>the <code>%</code>. That is, it’s easy to say <code>discount-10%</code> was meant to be <code>discount-10%25</code>, but not easy to know whether <code>newsletter-%signup</code> was meant to be <code>newsletter-%25signup</code> or if the <code>%</code> was a typo. I personally respect <code>URLSearchParams</code>’s take on this, that the <code>%</code> was intentional. </p><p>[2] Okay, their code doesn’t usually use a Map but a regular JS Object. I couldn’t in good conscience publish code that mangles param order, so my example uses a Map.😛 </p><p>[3] You don’t need to worry about this with <code>URLSearchParams</code> because it outputs <code>+</code><em> </em>but <em>accepts </em>either <code>%20</code> <em>or</em> <code>+</code>  as input.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ MktoForms2.whenReady() is always non-blocking (plus: what is and isn’t a race condition) ]]></title>
        <description><![CDATA[ I might be unhealthily obsessed with form events. But not testing against worst cases is how you get “randomly” broken forms. ]]></description>
        <link>https://blog.teknkl.com/mktoforms2-whenready-is-always-non-blocking-what-is-and-isnt-a-race-condition/</link>
        <guid isPermaLink="false">694cbf29877bb00001f2d237</guid>
        <category><![CDATA[ whenReady/whenRendered ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Sat, 03 Jan 2026 18:40:59 -0500</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2026/01/formsjs.png" medium="image"/>
        <content:encoded><![CDATA[ <p></p><em>Chronicles of Strange Code, §2026.01.03(a).</em><pre><code class="language-javascript">let enrichableForms = [123,678];
let formsOnPage = [];

MktoForms2.whenReady( function(readyForm){
  formsOnPage.push(readyForm.getFormId());
});
if( enrichableForms.some( id =&gt; formsOnPage.includes(id) ){
  // load an external JS plugin
}</code></pre><p>The code above was supposed to:</p><ol><li>add the ID of each in-use form to an array, then</li><li>check the array against a list of “interesting” form IDs, then</li><li>load an enrichment plugin if there’s a match</li></ol><p>Except it doesn’t do that. <mark><code>formsOnPage</code> will always be empty</mark> when checked. There’s <em>no condition under which this code works </em>as desired.</p><p><strong><em>“OK,” </em></strong>you might say. <strong><em>“I get how, if <code>loadForm</code> is just before this code, the forms won’t be done loading when <code>whenReady</code> is called. Is there a race condition, so it would work if a form had loaded, like, 5 minutes before?”</em></strong></p><p>Nope. There’s no race condition because the <mark>callback passed to <code>whenReady</code> always runs asynchronously</mark>. Doesn’t matter when forms become/became ready. Run this code:</p><pre><code class="language-javascript">console.log("banana");
MktoForms2.whenReady( function(readyForm){
   console.log("jalapeno");
   MktoForms2.whenReady( function(readyForm){
      console.log("shishito");
   })
   console.log("ghost");
});
console.log("serrano");</code></pre><p>100.00% of the time, the output will be:</p><pre><code class="language-console">⋖ banana
⋖ serrano
⋖ jalapeno
⋖ ghost
⋖ shishito</code></pre><p>Remember, a race condition means factors like <mark>network quality, server response time, local processing power, disk &amp; filesystem performance, and concurrently running code </mark>affect execution order.</p><p>That is, there must be an inadvertent “race” between components you can’t control — unlike here, where <em>one code path always wins</em>. Bugs related to execution order aren’t necessarily race conditions.</p><h2 id="assume-adding-an-event-listener-is-asynchronous">Assume adding an event listener is asynchronous</h2><p>Shouldn’t expect <code>MktoForms2.whenReady()</code> to work any other way. Native <code>addEventListener()</code> never runs the callback immediately, either!</p><p>For example, if you add a <code>DOMContentLoaded</code> listener when DOM content was <em>already </em>loaded, the callback doesn’t run. If you add it when DOM content isn’t loaded yet, the callback runs later. There’s no case where it runs immediately upon being added.<a href="#notes" rel="noreferrer">🞷</a></p><p>(Perhaps the author was half-remembering how <a href="https://api.jquery.com/ready/?ref=blog.teknkl.com#:~:text=the%20browser%20fires%20DOMContentLoaded%20before%20the%20code%20calls%20.ready(%20handler%20),%20the%20function%20handler%20will%20still%20be%20executed" rel="noreferrer"><code>jQuery.ready()</code></a> works. But even there, the callback runs asynchronously; the special part is that it runs <em>at all.</em>)  </p><h2 id="so-if-that%E2%80%99s-not-a-race-condition-what-is">So if that’s not a race condition, what is?</h2><p>With Marketo forms in particular, a race I sometimes see is loading the core library (<strong><em>forms2.min.js</em></strong>) asynchronously, yet not considering response time variance before accessing <code>MktoForms2</code>. Like this simplified example:</p><pre><code class="language-markup">&lt;form id="mktoForm_885"&gt;&lt;/form&gt;
&lt;script async src="https://123-ABC-456.mktoweb.com/js/forms2/js/forms2.min.js"&gt;&lt;/script&gt;
&lt;script src="https://someother.example.com/one.js"&gt;&lt;/script&gt;
&lt;script src="https://yetanother.example.net/two.js"&gt;&lt;/script&gt;
&lt;script&gt;
  if( window.MktoForms2 ) {
    MktoForms2.loadForm("//123-ABC-456.mktoweb.com", "123-ABC-456", 885);
  }
&lt;/script&gt;</code></pre><p>(In the real world, rather than an explicit <code>async</code> attribute, <mark>async is <em>implied </em>by using a tag manager like GTM to inject the library</mark>. Script-injected-scripts are always async.)</p><p>The code above is entirely dependent how long <strong><em>one.js </em></strong>+ <strong><em>two.js </em></strong>take to load compared to <em><strong>forms2.min.js</strong>:</em></p><ul><li>If those two external scripts load in in 70ms total but the library takes 71ms, the check for <code>window.MktoForms2</code> fails and there won’t be a form. </li><li>If the scripts take 71ms while the library takes 70ms, the check succeeds and the form will render.</li></ul><p>That fragility is obviously unacceptable.</p><p>Unfortunately, most developers (not just juniors) don’t test their code against adverse network/server conditions. If they did, this code would never make it to production. One of my testing habits is exaggerating the load time of async resources, say to a full 30 seconds, to make sure my code still works. (I run a special “slowpoke” server to help with this.)</p><p>This kind of race is obscured — but in no way solved! — when there’s a lot more than the two sync scripts <em><strong>one.js</strong> </em>and <strong><em>two.js</em></strong>. There could be a stack of 20 JS/CSS files between <strong><em>forms2.min.js</em> </strong>and the attempt to use <code>MktoForms2</code>, each subresource adding <em>just enough </em>consistent delay so the library finishes loading 99% of the time. </p><p>But go outside the comfort of your primary machine/connection and that’ll change, e.g. if you’re on a network where <strong><em>forms2.min.js </em></strong>takes 10ms more and other scripts take 10ms less.<a href="#notes" rel="noreferrer">🞷🞷</a> That difference is invisible to the end user, but fatal to your code.</p><h5 id="notes">Notes</h5>
<p>🞷 One native event-related feature that behaves a little weirdly is <mark>manually dispatching an Event</mark>. In this case, callbacks <a href="https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/dispatchEvent??ref=blog.teknkl.com#:~:text=dispatchEvent()%20invokes%20event%20handlers%20synchronously" rel="noreferrer">run synchronously to completion</a> before the calling code continues. Surprises can ensue, as the same <code>click</code> listener is either blocking or non-blocking depending on whether it’s a trusted click or a synthetic MouseEvent. But <code>addEventListener()</code> is still asynchronous! </p><p>🞷🞷 Note 4xx and 5xx errors can be much faster than 2xx responses. Pages with tons of random analytics/enrichment/tracking plugins eventually end up with errors from outdated vendors. Also consider <mark>ad blockers, which deliver near-instantaneous errors</mark>. If you <em>really </em>want to stress-test your code, ensure that a 60μs (60 microsecond!) response from disposable code doesn’t have any effect on mission-critical code like forms.   </p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Tip: Add a initial row number to Bulk Lead Import CSVs for useful failure reports ]]></title>
        <description><![CDATA[ Ideally, you should pre-flight CSVs (i.e. check for core errors) before sending ’em to the Bulk API. If you can’t do that, at least add a column to all CSVs to make post-processing easier. ]]></description>
        <link>https://blog.teknkl.com/tip-add-a-initial-row-number-to-bulk-lead-import-csvs-for-useful-failure-reports/</link>
        <guid isPermaLink="false">6940dc84ca9f9b000174d75a</guid>
        <category><![CDATA[ bulk import ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Wed, 24 Dec 2025 13:04:12 -0500</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/12/marketo_api.png" medium="image"/>
        <content:encoded><![CDATA[ <p>If you already <em>pre-flight CSVs </em>before putting them on the wire to the <em>Bulk Import </em>API (<code>/bulk/v1/leads.json</code>), you won’t notice this li’l bug. We recommend at least these two pre-flight checks:</p><ul><li>ensure the lookup field — <code>email</code>, <code>id</code>, <code>sfdcContactId</code>, etc. — is <em>non-empty </em>in every row</li><li>ensure field values are consistent with back-end data types, e.g. an Integer field can’t store a decimal or alphanumeric value</li></ul><p>There are tons of other checks you should do on CSVs, but I like to <mark>confine the term “pre-flight” to stuff Marketo will throw an error on</mark>. (Other checks are business-level and won’t stop the row from importing, for better or worse!)</p><p>If you pre-flight, you usually get the all-clear from the <em>Import Status </em>endpoint (<code>/bulk/v1/leads/batch/:importid.json</code>) so you don’t need to fetch <em>Import Failures </em>(<code>/bulk/v1/leads/batch/:importid/failures.json</code>).</p><p>But say you don’t pre-flight and just upload CSVs as-is. Then it’s possible for a CSV to lookup on <code>email</code> yet have rows with empty <code>email</code>:</p><pre><code class="language-markup">------WebKitFormBoundaryBQACkJZyaiIAXogC
Content-Disposition: form-data; name="file"; filename="leads.csv"
Content-Type: text/csv

firstName,lastName,phone,email
Joe,Hixie,212-555-1212,teknkl+test-import-01@figureone.com
Benji,Nordstrom,212-555-2323,
Dusty,Brix,212-555-3434,teknkl+test-import-03@figureone.com
Hank,Lovemore,212-555-4545,teknkl+test-import-04@figureone.com

------WebKitFormBoundaryBQACkJZyaiIAXogC--</code></pre><p>The <em>exact </em>payload above won’t cause any confusion. <em>Import Status </em>will return <code>"numOfRowsFailed": 1</code> and <em>Import Failures</em> returns a parseable CSV to feed into your error handling code:</p><pre><code class="language-markup">firstName,lastName,phone,Import Failure Reason
Benji,Nordstrom,212-555-2323,Value for lookup field 'email' not found</code></pre><p>If you reverse <code>email</code> and <code>phone</code> and leave that same value empty, you still get usable <em>Failures:</em></p><pre><code class="language-markup">------WebKitFormBoundaryBQACkJZyaiIAXogC
Content-Disposition: form-data; name="file"; filename="leads.csv"
Content-Type: text/csv

firstName,lastName,email,phone
Joe,Hixie,teknkl+test-import-01@figureone.com,212-555-1212
Benji,Nordstrom,,212-555-2323
Dusty,Brix,teknkl+test-import-03@figureone.com,212-555-3434
Hank,Lovemore,teknkl+test-import-04@figureone.com,212-555-4545

------WebKitFormBoundaryBQACkJZyaiIAXogC--</code></pre><pre><code class="language-markup">firstName,lastName,phone,Import Failure Reason
Benji,Nordstrom,212-555-2323,Value for lookup field 'email' not found</code></pre><p>Things get wacky, though, when you <mark>not only have a missing lookup value or other data error, you <em>also </em>have empty fields in the same row</mark>:</p><pre><code class="language-markup">------WebKitFormBoundaryBQACkJZyaiIAXogC
Content-Disposition: form-data; name="file"; filename="leads.csv"
Content-Type: text/csv

firstName,lastName,email,phone
Joe,Hixie,teknkl+test-import-02@figureone.com,212-555-1212
,Nordstrom,,
Dusty,Brix,teknkl+test-import-02@figureone.com,
Hank,Lovemore,,

------WebKitFormBoundaryBQACkJZyaiIAXogC--</code></pre><p> Then, the response gets mangled:</p><pre><code class="language-markup">lastName,Import Failure Reason
Nordstrom,Value for lookup field 'email' not found
Hank,Lovemore,Value for lookup field 'email' not found
</code></pre><p>Check the number of headers vs. the number of fields in data row 1 vs. row 2. <code>Nordstrom</code> was row 1’s <code>lastName</code>, yes, but <code>Hank</code> wasn’t row 2’s <code>lastName</code>, it was its <code>firstName</code>. That mangled response can’t be parsed to find the original offending row! Unfortunately, the API gets confused when error rows have empty fields.😦  </p><p>To be clear: empty fields are totally valid in themselves and won’t cause a problem in the absence of fatal import errors. Also, the bug appears whether the empty field is double-quoted <code>,"",</code> or simply empty <code>,,</code> — as you should expect because <mark>those have exactly the same meaning in a CSV</mark>.</p><p>Truth is, even the <em>non-mangled </em>response CSV is suboptimal, IMO. It lacks a row number, so to seek the row in the original file you need to match <em>every single field. </em>That’s really inefficient if you already parsed a source CSV into an indexed collection (e.g. JS array) even if you didn’t pre-flight.<a href="#notes" rel="noreferrer">[1]</a></p><p>Luckily, it’s easy to add a row number yourself, which patches over the bug while improving the <em>Import Failures </em>response in all cases. Add a <em>Last Import Row Number </em>(Integer) field in Marketo, and make that the <em>first </em>field in your CSV:  </p><pre><code class="language-markup">------WebKitFormBoundaryBQACkJZyaiIAXogC
Content-Disposition: form-data; name="file"; filename="leads.csv"
Content-Type: text/csv

lastImportRowNumber,firstName,lastName,email,phone
1,Joe,Hixie,teknkl+test-import-02@figureone.com,212-555-1212
2,,Nordstrom,,
3,Dusty,Brix,teknkl+test-import-02@figureone.com,
4,Hank,Lovemore,,

------WebKitFormBoundaryBQACkJZyaiIAXogC--</code></pre><p>Then the response reflects that field for easy seekin’:</p><pre><code class="language-markup">lastImportRowNumber,lastName,Import Failure Reason
2,Nordstrom,Value for lookup field 'email' not found
4,Hank,Lovemore,Value for lookup field 'email' not found</code></pre><p>The response still has an irregular number of fields per line, but you only need the <em>first </em>field so your code won't break.</p><h5 id="notes">Notes</h5>
<p>[1] To prepend <code>lastImportRowNumber</code>  you must <em>parse the CSV </em>before creating the request payload. So in the unlikely-but-not-impossible case where you have a <mark>guaranteed pre-pre-flighted CSV from an outside source</mark> and want to just stream it into the payload (admittedly, the most efficient way to send a static file, as long as you <a href="https://blog.teknkl.com/easy-mistake-marketo-bulk-import-api-payload/" rel="noreferrer">skip the BOM</a>) you can't do that anymore.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ No, you don’t need a Wait step after Sync Person to SFDC: sync-related updates are immediate (i.e. synchronous) ]]></title>
        <description><![CDATA[ Don’t let paranoia get the better of you: the Marketo-SFDC connector is very smart about order of operations. ]]></description>
        <link>https://blog.teknkl.com/no-you-dont-need-a-wait-step-after-sync-person-to-sfdc-sync-related-updates-are-immediate/</link>
        <guid isPermaLink="false">6922b8b7a5f39b0001525b85</guid>
        <category><![CDATA[ triggers ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Tue, 02 Dec 2025 21:55:00 -0500</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/11/marketo_quirks-1.png" medium="image"/>
        <content:encoded><![CDATA[ <figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/11/image-15.png" class="kg-image" alt="" loading="lazy" width="1437" height="789" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/11/image-15.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/11/image-15.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/11/image-15.png 1437w" sizes="(min-width: 720px) 720px"></figure><p>We talk a lot about race conditions in the Marketo community, and I love reminding folks certain operations aren’t guaranteed to complete in flow step order.<a href="#notes" rel="noreferrer">[1]</a> Unfortunately, users tend to overcorrect, forgetting Marketo <mark><em>does </em>guarantee order in many cases</mark>, just not <em>all </em>sensitive cases!</p><p>This came up on a recent Nation thread where someone’s new lead flow included <em>Sync Person to SFDC, </em>followed by a 5-minute <em>Wait, </em>then <em>Send Email.<strong> “Exactly what is the Wait step for?” </strong></em>I asked. </p><p>Unfortunately, the OP ghosted the thread, but others guessed <strong><em>“To make sure the sync has actually happened.” </em></strong>They were thinking the email, which included a booking link, shouldn't go out until the lead is visible to sales.</p><p>But that’s wrong on 2 counts:</p><ul><li>First, a <em>Wait </em>step is unnecessary because <mark><em>Sync Person </em>is guaranteed to finish before the next step runs</mark>. If the sync succeeds, field updates triggered on the SFDC side will be synced to Marketo immediately. <em>After </em>the bidirectional sync, the API connection closes and it’s on to the next step. </li><li>Second, even if a <em>Wait </em>were in there (again, totally unnecessary!) <em>Sync Person </em>merely <em>finishing </em>doesn’t mean the record was created in SFDC. Finishing includes both success and failure. <mark>A validation error from SFDC means the person is still Marketo-only.</mark></li></ul><h2 id="a-closer-look-at-sync-person-success">A closer look at <em>Sync Person </em>success</h2><p>Let’s run a real-world test. The flow runs <em>Sync Person to SFDC </em>and immediately logs an <em>Interesting Moment — </em>no <em>Wait — </em>and crucially, <mark>the IM description includes <em>Lead Source, </em>a field that’s only populated when Marketo gets the value <em>from </em>SFDC</mark>:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/11/image-15.png" class="kg-image" alt="" loading="lazy" width="1437" height="789" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/11/image-15.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/11/image-15.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/11/image-15.png 1437w" sizes="(min-width: 720px) 720px"></figure><p>Here’s the corresponding activity log:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/11/image-16.png" class="kg-image" alt="" loading="lazy" width="2000" height="925" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/11/image-16.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/11/image-16.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1600/2025/11/image-16.png 1600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/11/image-16.png 2045w" sizes="(min-width: 720px) 720px"></figure><p>As you can see, <em>Sync Person </em>runs and is followed by a host of other data value changes fetched from SFDC. After all those updates comes the <em>Interesting Moment</em>, with <em>Lead Source </em>“Import (Employee Referral)” populated in the IM as desired.</p><p>You might wonder if the updates and IM are happening in parallel, since Marketo only shows minutes in the UI. Nope! The JSON response shows the last update at 02:26:17 and the IM at 02:26:18, one second later:</p><pre><code class="language-javascript">{
    "resultCode": "Succeeded",
    "actionName": "Interesting Moment",
    "detail": "SFDC Sync: Person Source (from SFDC) is Import (Employee Referral)!",
    "dateAndTime": "2025-11-23 02:26:18",
    "historyLogId": 886995328,
    "assetId": null,
    "detailJson": {
        "Choice Number": 0,
        "Description": "Person Source (from SFDC) is Import (Employee Referral)!",
        "Step ID": 130078,
        "Source": "Lead action",
        "operator": "is",
        "Date": "2025-11-23 01:26:18",
        "datatype": "string",
        "Lead ID": 33945529,
        "Type": "SFDC Sync"
    },
    "campaignId": 45378,
    "campaignName": "TEST Program &gt; Engagement &gt; Nurture SMR.Sync to SFDC and do stuff right after",
    "actionTypeId": 46,
    "actionAssetType": "mktInterestingMoment",
    "date": "2025-11-23 02:26:18",
},
{
    "resultCode": "Succeeded",
    "actionName": "Change Data Value",
    "detail": "Changed Discovery Data Status from [null] to \"&lt;img src=\"https://example.my.salesforce.com/resource/Data_Link2__No_ID\" alt=\"\" border=\"0\"/&gt; No ID\"",
    "dateAndTime": "2025-11-23 02:26:17",
    "historyLogId": 886995327,
    "assetId": 2826,
    "detailJson": {
        "Choice Number": 0,
        "Reason": "Synched from salesforce.com Lead ID 00QNw00000MsRMkAAD",
        "DB Object ID": 2,
        "Attribute Name": "Discovery Data Status",
        "Step ID": 130077,
        "New Value": "&lt;img src=\"https://vaneck.my.salesforce.com/resource/Data_Link2__No_ID\" alt=\"\" border=\"0\"/&gt; No ID",
        "Old Value": null,
        "Source": "sfdc",
        "Lead ID": 33945529,
        "Attribute ID": 2826,
        "isCustomerCreated": true
    },
    "campaignId": 45378,
    "campaignName": "TEST Program &gt; Engagement &gt; Nurture SMR.Sync to SFDC and do stuff right after",
    "actionTypeId": 13,
    "actionAssetType": "mktDbAttribute",
    "date": "2025-11-23 02:26:17",
}</code></pre><h2 id="capturing-sync-person-failure">Capturing <em>Sync Person </em>failure</h2><p>Mentioned above that <em>Sync Person </em>completing, whether you <em>Wait </em>for it or not, doesn’t mean the record was successfully upserted into SFDC: it just means Marketo <em>tried </em>to upsert.</p><p>To proceed only if <em>Sync Person </em>was successful, use a (second) campaign triggering on <em>Person is Synced to SFDC: </em>  </p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/11/image-12.png" class="kg-image" alt="" loading="lazy" width="1437" height="456" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/11/image-12.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/11/image-12.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/11/image-12.png 1437w" sizes="(min-width: 720px) 720px"></figure><p>Marketo <mark>only fires this trigger if the person was actually synced</mark>. </p><p>So you never need a <em>Wait, </em>but depending on how worried you are about sync failures, you may need a more complex overall setup.</p><h5 id="notes">Notes</h5>
<p>[1] Updates from <em>Call Webhook</em> response mappings, for example. That’s why you need the <em>Webhook is Called</em> trigger.</p>
 ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Marketo click tracking only works because 2 JavaScript bugs cancel each other out ]]></title>
        <description><![CDATA[ Code that works by luck rather than by design? Yikes. A brief code review would’ve caught this one. ]]></description>
        <link>https://blog.teknkl.com/marketo-click-tracking-only-works-because-2-js-bugs-cancel-each-other-out/</link>
        <guid isPermaLink="false">692ccc5a1b380e0001e1c430</guid>
        <category><![CDATA[ JS ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Tue, 02 Dec 2025 21:45:29 -0500</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/11/js.png" medium="image"/>
        <content:encoded><![CDATA[ <p>An uncomfortable truth: the tiny bit of JavaScript that runs on Marketo’s click tracking servers (i.e. branding domains) has 2 pretty embarrassing bugs, but <mark>the resulting logic — by accident — works just fine!</mark></p><p>Here’s the code, which runs 10s of millions of times per day: </p><pre><code class="language-javascript">var redirecturl = 'https://www.example.com/landingpage?mkt_tok=NDEwLVhPUi02NzMAAA...';
document.getElementsByTagName('head')[0].addEventListener('load',redirect());
function redirect() {
  var anchor = window.location.hash;
  window.self.location = redirecturl + anchor;
}</code></pre><p>If you suspend disbelief, this code <em>seems to</em>:</p><ul><li>add a <code>load</code> event listener to the first <code>&lt;head&gt;</code> in the document</li><li>call the <code>redirect</code> function when that <code>load</code> event fires</li></ul><h2 id="but-head-elements-don%E2%80%99t-fire-a-load-event">But <code>&lt;head&gt;</code> elements don’t fire a load event</h2><p>Except for a special few, like <code>&lt;img&gt;</code>, <code>&lt;script&gt;</code>, and <code>&lt;iframe&gt;</code><a href="#notes" rel="noreferrer">[1]</a>, elements don’t natively fire <code>load</code>. <code>&lt;head&gt;</code> isn’t one of the specials.</p><p>All elements are valid EventTargets — meaning you can call <a href="https://dom.spec.whatwg.org/?ref=blog.teknkl.com#ref-for-dom-eventtarget-addeventlistener%E2%91%A2" rel="noreferrer"><code>addEventListener(type,callback,options)</code></a>, passing any string for <code>type</code>, without an error. But the <code>load</code> event will not fire unless the element supports it.<a href="#notes" rel="noreferrer">[2]</a></p><p>So it’s impossible for a callback to run when <code>&lt;head&gt;</code> loads.<a href="#notes" rel="noreferrer">[3]</a></p><h2 id="and-the-event-listener-isn%E2%80%99t-even-added">And the event listener isn’t even added!</h2><p>To <a href="https://dom.spec.whatwg.org/?ref=blog.teknkl.com#interface-eventtarget" rel="noreferrer">add an event listener</a> to an element, the <code>callback</code> argument must either be a JS function or a JS object with a function property <code>handleEvent</code>. Any other value doesn’t match what the spec calls the “callback interface,” in which case the effective callback is null and there’s no event listener added:<a href="#notes" rel="noreferrer">[4]</a></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/12/image.png" class="kg-image" alt="" loading="lazy" width="1474" height="340" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/12/image.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/12/image.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/12/image.png 1474w" sizes="(min-width: 720px) 720px"></figure><p>And guess what? The code isn’t adding a valid callback interface. It’s adding <mark>the return value of running <code>redirect()</code></mark> which is <code>undefined</code>. Even if <code>&lt;head&gt;</code> <em>did </em>fire <code>load</code>, nothing gets added to the event listener stack in the first place.</p><h2 id="so-why-does-it-still-redirect-correctly">So why does it still redirect correctly?</h2><p>Remember, <mark>the 2nd arg is <code>redirect()</code>, not <code>redirect</code>.</mark>  While the <em>intent </em>was to run <code>redirect</code> on some event (like the <code>&lt;head&gt;</code> being “ready,” which isn’t a thing), instead it runs immediately. Yet by luck, <code>redirect()</code> already has everything it needs, since <code>redirecturl</code> is set on line 1 and <code>window.location.hash</code> is always available.<a href="#notes" rel="noreferrer">[5]</a></p><p>The executed code is therefore identical to: </p><pre><code class="language-javascript">var redirecturl = 'https://www.example.com/landingpage?mkt_tok=NDEwLVhPUi02NzMAAA...';
redirect();
function redirect() {
  var anchor = window.location.hash;
  window.self.location = redirecturl + anchor;
}</code></pre><p>The current code is extremely fragile, not understood by its maintainers, and should be fixed. But for all that, it ❝<strong>works</strong>❞ — in giant quotes — so well that nobody noticed it was buggy across likely <em>trillions </em>of visits! Go figure.</p><h5 id="notes">Notes</h5>
<p>[1] Not an exhaustive list, other <code>load</code>-ables include <code>&lt;style&gt;</code>, <code>&lt;link&gt;</code> and <code>&lt;object&gt;</code>.</p><p>[2] Yes, you can programmatically dispatch an untrusted event like <code>document.head.dispatchEvent(new Event("load"))</code>. That’s not the same!</p><p>[3] Unclear what “load” would even mean, presumably something akin to <a href="https://html.spec.whatwg.org/dev/custom-elements.html?ref=blog.teknkl.com#custom-element-reactions" rel="noreferrer"><code>connectedCallback()</code></a>. </p><p>[4] That’s why <code>element.addEventListener("click", 999)</code> or <code>element.addEventListener("click", { bzzt: function(){} )</code> won’t throw <code>... is not a function</code> errors on click. The listener isn’t added if not callback-compatible, so there’s no attempt to run it.</p><p>[5] And of course <code>redirect</code> itself is <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Functions?ref=blog.teknkl.com#function_hoisting" rel="noreferrer">hoisted</a> so it can be called before it’s lexically declared.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Deleting the non-deletable: clear an overridden {{my.token}} using the Marketo API ]]></title>
        <description><![CDATA[ You don’t need to accept “Token is already in use” as a permanent condition. ]]></description>
        <link>https://blog.teknkl.com/deleting-the-non-deletable-clear-an-overridden-my-token-using-the-api/</link>
        <guid isPermaLink="false">691ffab7a5f39b0001525aaf</guid>
        <category><![CDATA[ my.token ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Fri, 21 Nov 2025 00:54:41 -0500</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/11/token_tricks_v2.png" medium="image"/>
        <content:encoded><![CDATA[ <p>The longstanding “Token is already in use and cannot be deleted” bug is the subject of several frustrated Nation threads I won’t bother linking here. </p><p>Worse, no one is 100% sure when an overridden {{my.token}} becomes non-deleteable. (Some say it’s only non-deletable when it’s inside a <code>&lt;div&gt;</code>, which I don’t believe, and even if it were true that’s not something you can avoid!)</p><p>To my surprise, no one seems to have tested the <a href="https://developer.adobe.com/marketo-apis/api/asset/?ref=blog.teknkl.com#operation/deleteTokenByNameUsingPOST" rel="noreferrer"><em>Delete Token</em></a><em> </em>API endpoint for this case, and it works.</p><p>First step was to reproduce the error. Overriding the token, then <em>unapproving and reapproving </em>an asset that uses it, reliably popped it up:  </p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/11/image-4.png" class="kg-image" alt="snippet of Marketo program » Tokens tab showing Overridden {{my.Hostname}} text token followed by miscellaneous Inherited tokens" loading="lazy" width="748" height="250" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/11/image-4.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/11/image-4.png 748w" sizes="(min-width: 720px) 720px"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/11/image-2.png" class="kg-image" alt="Marketo “Not Allowed” popup with the text “Token is already in use and cannot be deleted”" loading="lazy" width="762" height="543" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/11/image-2.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/11/image-2.png 762w" sizes="(min-width: 720px) 720px"></figure><p>Then I sent this simple <code>application/x-www-form-urlencoded</code> payload to <code>/rest/asset/v1/folder/{:programId}/tokens/delete.json</code>:</p><pre><code class="language-markup">name=Hostname&amp;type=text&amp;folderType=Program</code></pre><p>And it’s gone:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/11/image-3.png" class="kg-image" alt="snippet of Marketo program » Tokens tab showing only miscellaneous Inherited tokens" loading="lazy" width="748" height="250" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/11/image-3.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/11/image-3.png 748w" sizes="(min-width: 720px) 720px"></figure><p>So unless I missed some other case, the API works around this bug.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Why you 𝒅𝒐𝒏’𝒕 need to check if GTM’s window.dataLayer was blocked by an ad blocker ]]></title>
        <description><![CDATA[ An exceptional case where you’d think code isn’t resilient, but it mysteriously is! ]]></description>
        <link>https://blog.teknkl.com/why-you-dont-need-to-check-if-gtms-datalayer-was-blocked-by-an-ad-blocker/</link>
        <guid isPermaLink="false">691ba375d6f332000162fae3</guid>
        <category><![CDATA[ GTM ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Wed, 19 Nov 2025 02:00:00 -0500</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/11/analytics.png" medium="image"/>
        <content:encoded><![CDATA[ <p>The standard pattern for logging Marketo form fills to GTM Data Layer is like so:</p><pre><code class="language-javascript">MktoForms2.whenReady(function(readyForm){
  readyForm.onSuccess(function(submittedValues, followUpUrl) {
    window.dataLayer.push({
        "event": "mkto.form.success",
        "mkto.form.values": submittedValues,
        "mkto.form.followUpUrl": followUpUrl,
        "eventCallback": function () {
            document.location.href = followUpUrl;
        },
        "eventTimeout": 3000
    });
    return false;
  });
});</code></pre><p>As you probably know, returning <code>false</code> from the <code>onSuccess</code> listener stops Marketo from immediately redirecting. Then GTM handles the redirect when its work is done. (That’s assuming you want to go to the original <code>followUpUrl</code>. If not, tweak the <code>eventCallback</code> to do something fancier.)</p><p>The <code>eventTimeout</code> means if GTM doesn’t get confirmation of the Data Layer update in 3s, run the <code>eventCallback</code> anyway. That way an internal GTM problem won’t cause a user-facing error.</p><p>What’s notable is this snippet <em>doesn’t </em>have a bug. Quite the opposite: <mark>the code is resilient, even if GTM is blocked by an ad blocker</mark>. Which, when you think about it, doesn’t make sense!</p><h2 id="if-the-main-gtmjs-doesn%E2%80%99t-load-how-the-heck-does-the-eventcallback-fire">If the main <strong><em>gtm.js</em></strong> doesn’t load, how the heck does the <code>eventCallback</code>  fire?</h2><p>It still fires because <mark>ad blockers <em>include their own </em>“callback only” version of <code>window.dataLayer</code>.</mark></p><p>To deploy GTM, you include the standard GTM bootstrap (i.e. embed code) on your page:</p><pre><code class="language-markup">&lt;!-- Google Tag Manager --&gt;
&lt;script&gt;(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&amp;l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-BLAHBLAH');&lt;/script&gt;
&lt;!-- End Google Tag Manager --&gt;</code></pre><p>The bootstrap creates a <em>dumb </em>version of the <code>window.dataLayer</code> variable, which is just a JavaScript array. This version doesn’t do anything but store events for the <em>real </em>GTM library to process when it’s ready.<a href="#notes" rel="noreferrer">[1]</a> It’s incapable of running callbacks or timing out. Basically, it sits there waiting for <strong><em>gtm.js</em></strong>.</p><p>Not all ad blockers block the core <strong><em>gtm.js</em> </strong>library: as of this writing, AdBlock Plus doesn’t block it, but uBlock Origin and Ghostery do.<a href="#notes" rel="noreferrer">[2]</a> When they do block <code>www.googletagmanager.com</code>,  you’ll never see Data Layer Events (that’s the intent of blocking it, after all). But this section of the uBlock Origin extension code shows how they keep things otherwise running: </p><pre><code class="language-javascript">    const dl = w.dataLayer;
    if ( dl instanceof Object ) {
        if ( dl.hide instanceof Object &amp;&amp; typeof dl.hide.end === 'function' ) {
            dl.hide.end();
            dl.hide.end = ()=&gt;{};
        }
        if ( typeof dl.push === 'function' ) {
            const doCallback = function(item) {
                if ( item instanceof Object === false ) { return; }
                if ( typeof item.eventCallback !== 'function' ) { return; }
                setTimeout(item.eventCallback, 1);
                item.eventCallback = ()=&gt;{};
            };
            dl.push = new Proxy(dl.push, {
                apply: function(target, thisArg, args) {
                    doCallback(args[0]);
                    return Reflect.apply(target, thisArg, args);
                }
            });
            if ( Array.isArray(dl) ) {
                const q = dl.slice();
                for ( const item of q ) {
                    doCallback(item);
                }
            }
        }
    }</code></pre><p>They check whether the “dumb” version of <code>window.dataLayer</code> exists — that is, whether you included the GTM bootstrap. If so, using a JavaScript Proxy, they substitute a more capable “callback only” version that just runs any <code>eventCallback</code> after a millisecond. Pretty cool, I think!</p><h5 id="notes">Notes</h5>
<p>[1] <code>window.dataLayer.push()</code> works immediately after the bootstrap code, even though <strong><em>gtm.js </em></strong>isn’t loaded yet, because you’re calling native <code>Array.prototype.push</code>! <em><strong>gtm.js </strong></em>eventually replaces <code>window.dataLayer</code> with <em>another </em>array whose <code>push</code> property is overwritten with a custom function.</p><p>[2] Ghostery uses uBlock Origin’s open source code internally, so not surprising. </p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Check out my colleague Jo Pitts’s blog, too ]]></title>
        <description><![CDATA[ Jo &amp; I are kindred spirits (and collaborators) and the revived MoTaM blog is highly recommended. ]]></description>
        <link>https://blog.teknkl.com/check-out-jo-pitts-motam-blog-too/</link>
        <guid isPermaLink="false">691d52a3a128d70001465433</guid>
        <category><![CDATA[ Jo Pitts ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Wed, 19 Nov 2025 00:44:53 -0500</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/11/marketo_api.png" medium="image"/>
        <content:encoded><![CDATA[ <p>You may already know Jo Pitts — a fellow moderator on the official Adobe Marketo community, member of the usual 3rd-party channels, and longtime collaborator with yours truly.</p><p>Jo’s in my <em>Martech Developers I Trust Implicitly </em>group chat (along with Joe Fusaro @ Datachef and Todd Sprinkel @ Sponge*) and after a hiatus, he just started blogging again. The kickoff post is on the Deleted Leads API endpoint. Check it out and subscribe <a href="https://motam.co.nz/blog?ref=blog.teknkl.com" rel="noreferrer">here</a>.  </p><hr>
<p>* <em>If you want your name dropped, we’ve gotta at least have monthly catchups.</em>😛</p>
 ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Using a Marketo form to register people in a Teams webinar (via FlowBoost) ]]></title>
        <description><![CDATA[ You can’t stop Teams, you can only hope to integrate it. ]]></description>
        <link>https://blog.teknkl.com/using-a-marketo-form-to-register-people-in-a-teams-webinar-via-flowboost/</link>
        <guid isPermaLink="false">6904303950f5dc0001f0e03e</guid>
        <category><![CDATA[ webinars ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Fri, 31 Oct 2025 03:51:25 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/10/flowboostjs_new.png" medium="image"/>
        <content:encoded><![CDATA[ <p>Like many of you, I try to avoid the Teams ecosystem (though I’m a Windows guy in every other way). But Teams webinars are so easy to set up — <em>Calendar » New » Webinar </em>— that inevitably someone’ll ask if you can use Marketo to register people.</p><p>Indeed you can. There’s no Marketo connector comparable to Zoom and ON24, but if all you want to do is add registrants to a Marketo program (via a Marketo form or any other way) and push the registration to Teams, it’s very easy with a FlowBoost webhook.</p><h2 id="%E2%9A%A0%EF%B8%8F-flowboost-standard-required-sorry">⚠️ FlowBoost Standard required (sorry)</h2><p>The vast majority of FlowBoost users are on the free Community plan, since they make &lt;&nbsp;50,000 calls per month and don’t need the few extra features offered by Standard.</p>
<p>Every <em>other </em>post on FlowBoost, give or take, works with Community. But this one’s connecting to a non-Marketo service, which requires Standard.<a href="#notes" rel="noreferrer">[1]</a> We’re grateful to the core group of Standard users that help us keep the lights on and hope you’ll join them if your org needs this functionality. Reach out for a Standard trial key.😀</p><p>And now, on to the code.</p><h2 id="teams-tenant-id-and-event-id-are-all-you-need">Teams <em>Tenant ID</em> and <em>Event ID </em>are all you need</h2><p>A Microsoft subscription has a single <em>Tenant ID </em>shared across all users. Each webinar has its own <em>Event ID. </em>You can get both of those from the Teams-hosted Event URL, which follows this structure:</p><pre><code class="language-markup">https://events.teams.microsoft.com/event/{{Microsoft Teams Event ID}}@{{Microsoft Teams Tenant ID}}</code></pre><p><mark>We won’t be sharing that URL for registration,</mark> obviously, but we need it to grab the IDs:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/10/image-18.png" class="kg-image" alt="" loading="lazy" width="1224" height="780" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/10/image-18.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/10/image-18.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/10/image-18.png 1224w" sizes="(min-width: 720px) 720px"></figure><p>Once you copy those out (remember, it’s <code>event@tenant</code>) put the <em>Tenant ID </em>in a global token <code>{{my.Microsoft Teams Tenant ID}}</code>. Drop the <em>Event ID </em>into a program-level <code>{{my.Microsoft Teams Event ID}}</code> for each webinar.</p><p>Then set up this FlowBoost webhook and call it from a trigger campaign in any program:</p><pre><code class="language-javascript">const eventId = {{my.Microsoft Teams Event ID}};
const tenantId = {{my.Microsoft Teams Tenant ID}};

const email = {{lead.Email Address}};
const firstName = {{lead.First Name}} || "[Not Provided]";
const lastName = {{lead.Last Name}} || "[Not Provided]";
const ianaTimeZone = {{lead.Person Time Zone}} || "America/New_York";
const language = {{lead.Preferred Language}} || "en-US";

const getTokenURL = () =&gt;
  `https://authsvc.teams.microsoft.com/v1.0/authz/visitor`;
const getRegisterURL = (eventId, tenantId) =&gt;
  `https://teams.microsoft.com/api/virtualevents/prod/events/${encodeURIComponent(eventId)}@${encodeURIComponent(tenantId)}/attendee/register`;

const CONTENT_TYPE_JSON = { "content-type": "application/json" };

FBHttp.fetch(getTokenURL(), {
  method: "POST",
  headers : { ...CONTENT_TYPE_JSON },
  body: JSON.stringify({
      tenantId
    })
  })
  .then((tokenRespJ) =&gt; tokenRespJ.json())
  .then((tokenResp) =&gt; {

    return FBHttp.fetch(getRegisterURL(eventId, tenantId), {
      method: "POST",
      headers : { "x-skypetoken": tokenResp.tokens.skypeToken, ...CONTENT_TYPE_JSON },
      body: JSON.stringify({
        firstName,
        lastName,
        email,
        language,
        timeZoneDetails: {
          ianaTimeZone
        },
        answers: [],
        hasAcceptedTermsOfUse: null
      })
    })
    .then((registerRespJ) =&gt; registerRespJ.json())
    .then(success)
  })
  .catch(failure)</code></pre><p>It’s really simple:</p><p>(1) get the rather nostalgically named <code>x-skypetoken</code> from the Teams <em>auth </em>endpoint<br>(2) pass the <code>x-skypetoken</code> together with lead fields to the Teams <em>register </em>endpoint</p><p>The person is immediately registered and Teams will send them a confirmation email and all follow-up emails.</p><p>The code assumes you have a person field for <code>{{lead.Preferred Language}}</code>. If not, hard-code your default corporate language. Or have a <code>{{my.token}}</code> if language varies at the webinar level, it’s up to you!</p><h2 id="it%E2%80%99s-a-unidirectional-registration-time-sync">It’s a <em>unidirectional, registration-time </em>sync</h2><p>With these endpoints, it’s impossible to get a <code>joinWebUrl</code> in the webhook response (Microsoft doesn’t provision it until <em>after </em>the registration.) So emails must still be sent via Teams as usual. </p><p>Like I said, it’s no match for the Zoom and ON24 connectors, but it lets you present a nicely branded Marketo LP + form and work directly with Marketo program membership. You’re in control of registration, so you can also use FBCounter to limit registrants — that’ll mesh together nicely with the back end.</p><h2 id="if-you%E2%80%99re-on-very-good-terms-with-it-we-can-go-further">If you’re on very good terms with IT, we can go further    </h2><p>There <em>is </em>a way to fetch <code>joinWebUrl</code> using the <a href="https://learn.microsoft.com/en-us/graph/api/resources/virtualeventregistration?view=graph-rest-beta&ref=blog.teknkl.com" rel="noreferrer">Graph API</a>, getting you closer to an all-Marketo solution. However, coordinating Graph API <a href="https://learn.microsoft.com/en-us/graph/cloud-communication-online-meeting-application-access-policy?ref=blog.teknkl.com" rel="noreferrer">tenant permissions</a> with your IT team could be hairy. For FlowBoost Standard users, I’m happy to try to get that worked out with IT, after which I’ll provide the updated code, but can’t guarantee they’ll go for it.</p><h5 id="notes">Notes</h5>
<p>[1] FlowBoost Community keys can only connect to Marketo APIs and assets. (Technically, Community can connect to <em>any </em>Marketo instance, not just the one calling the webhook.) Standard is required to hit SFDC, Teams, or other remote APIs.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Using the SFDC Type in Dynamic Chat audience criteria ]]></title>
        <description><![CDATA[ Finally opened the hood on Adobe Dynamic Chat, revealing a familiar mix of overall coolness + unexpected quirks. As with everything Marketo, workarounds abound. ]]></description>
        <link>https://blog.teknkl.com/using-the-sfdc-type-in-dynamic-chat-audience-criteria/</link>
        <guid isPermaLink="false">69024233144f480001c08d6d</guid>
        <category><![CDATA[ dynamic chat ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Wed, 29 Oct 2025 17:09:30 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/10/marketo_quirks.png" medium="image"/>
        <content:encoded><![CDATA[ <p>Popping up different Dynamic Chat dialogues for known SFDC Contacts vs. SFDC Leads is a basic ask. It’s a <em>smidge </em>trickier than you might expect, though, for 2 reasons:</p><p>(1) you can’t sync <em>SFDC Type </em>from Marketo to the DC database<br>(2) you can’t sync <em>SFDC Is Deleted, </em><a href="https://blog.teknkl.com/without-checking-sfdc-is-deleted-you-dont-know-if-someones-synced-to-sfdc/" rel="noreferrer">another key component</a></p><p>Don’t worry, it’s still easy. </p><h2 id="mirror-sfdc-is-deleted">Mirror <em>SFDC Is Deleted</em></h2><p>Create a custom Boolean field <em>SFDC Is Deleted</em> <em>Mirror</em>. Keep that field updated with a simple trigger campaign:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/10/image-17.png" class="kg-image" alt="" loading="lazy" width="1140" height="402" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/10/image-17.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/10/image-17.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/10/image-17.png 1140w" sizes="(min-width: 720px) 720px"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/10/image-16.png" class="kg-image" alt="" loading="lazy" width="1140" height="444" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/10/image-16.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/10/image-16.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/10/image-16.png 1140w" sizes="(min-width: 720px) 720px"></figure><h2 id="sync-sfdc-contact-id-sfdc-lead-id-and-the-new-mirror-field">Sync <em>SFDC Contact ID, SFDC Lead ID,</em> and the new mirror field</h2><p>With the mirror field in hand, you have everything you need to reproduce <em>SFDC Type </em>in Dynamic Chat:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/10/image-15.png" class="kg-image" alt="" loading="lazy" width="1311" height="378" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/10/image-15.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/10/image-15.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/10/image-15.png 1311w" sizes="(min-width: 720px) 720px"></figure><h2 id="build-your-audience-criteria">Build your audience criteria</h2><p>Now you can match SFDC Contacts:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/10/image-14.png" class="kg-image" alt="" loading="lazy" width="1269" height="372" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/10/image-14.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/10/image-14.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/10/image-14.png 1269w" sizes="(min-width: 720px) 720px"></figure><p>And SFDC Leads:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/10/image-13.png" class="kg-image" alt="" loading="lazy" width="1272" height="423" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/10/image-13.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/10/image-13.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/10/image-13.png 1272w" sizes="(min-width: 720px) 720px"></figure><p>Not too intimidating, right?</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Using native HTML ˂dialog˃ for modal Marketo forms instead of legacy lightbox() ]]></title>
        <description><![CDATA[ Forms 2.0’s lightbox() is fine (if misunderstood) but unless you’re supporting IE, it’s time to move on to the new stuff. ]]></description>
        <link>https://blog.teknkl.com/native-dialog-element-instead-of-marketo-lightbox-method/</link>
        <guid isPermaLink="false">63afb0b9d62c24003dc17a21</guid>
        <category><![CDATA[ Marketo forms ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Tue, 28 Oct 2025 01:52:08 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/10/justforms.png" medium="image"/>
        <content:encoded><![CDATA[ <p>The Forms 2.0 <code>lightbox()</code> method is fully functional, but commonly misused.<a href="#notes" rel="noreferrer">[1]</a> I’ve had a post in my drafts for ages — <strong><em>“Tips for a smoother <code>lightbox()</code> experience” — </em></strong>but in the meantime <code>lightbox()</code> has become unnecessary because all modern browsers support <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog?ref=blog.teknkl.com" rel="noreferrer"><code>&lt;dialog&gt;</code></a>.</p><p>IMO, the coolest things about <code>&lt;dialog&gt;</code> are:</p><ul><li>built-in responsiveness: desktop and mobile browsers automatically position the modal correctly</li><li>native controls: the Escape key and back-swipe both close the dialog</li><li>easier markup and styling: no need for extra <code>&lt;div&gt;</code>s and <code>z-index</code> juggling, just style <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/::backdrop?ref=blog.teknkl.com" rel="noreferrer"><code>::backdrop</code></a> and <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/backdrop-filter?ref=blog.teknkl.com" rel="noreferrer"><code>::backdrop-filter</code></a></li></ul><p>Secondarily, <code>&lt;dialog&gt;</code> minimizes the need for JavaScript. The JS in this post is only necessary because Safari doesn’t support <a href="https://developer.mozilla.org/en-US/docs/Web/API/Invoker_Commands_API?ref=blog.teknkl.com" rel="noreferrer">Invoker Commands</a> yet. In Chrome and Firefox, you can close and open the modal using HTML alone, no extra JS!</p><p>Here’s a modal Marketo form inside a <code>&lt;dialog&gt;</code>:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/10/image-12.png" class="kg-image" alt="" loading="lazy" width="997" height="721" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/10/image-12.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/10/image-12.png 997w" sizes="(min-width: 720px) 720px"></figure><p>The markup is amazingly simple, just put the standard form embed inside a <code>&lt;dialog&gt;</code> and add a close <code>&lt;button&gt;</code><a href="#notes" rel="noreferrer">[2]</a>:</p><pre><code class="language-markup">&lt;dialog id="mktoFormDialog_1549"&gt;
  &lt;!-- standard form embed --&gt;
  &lt;script src="//app-sj01.marketo.com/js/forms2/js/forms2.min.js"&gt;&lt;/script&gt;
  &lt;form id="mktoForm_1549"&gt;&lt;/form&gt;
  &lt;script&gt;MktoForms2.loadForm("//app-sj01.marketo.com", "123-XOR-456", 1549);&lt;/script&gt;
  &lt;!-- /standard form embed --&gt;
  &lt;button type="button" commandfor="mktoFormDialog_1549" command="close"&gt;✖&lt;/button&gt;
&lt;/dialog&gt;</code></pre><p>Then anywhere else on the page, add a <code>&lt;button&gt;</code> (or multiple <code>&lt;button&gt;</code>s) that open the <code>&lt;dialog&gt;</code>:</p><pre><code class="language-markup">&lt;button type="button" commandfor="mktoFormDialog_1549" command="show-modal"&gt;Show modal form&lt;/button&gt;</code></pre><p>The above works without JS in Chrome/Edge/Firefox, including Android versions. Gotta support Safari too, though. This bit of code maps <code>commandfor</code> and <code>command</code> attributes to corresponding JS methods:</p><pre><code class="language-javascript">/* only required in Safari (a/o 2025-10-26) */
if( typeof CommandEvent !== "function" ) {
  MktoForms2.whenReady(function(readyForm){
  
    const formEl = readyForm.getFormElem()[0];
    const dialog = formEl.closest("dialog");
  
    if( dialog ) {
      const invokers = document.querySelectorAll(`button[commandfor="${CSS.escape(dialog.id)}"]`);
      invokers.forEach( (invoker) =&gt; {
        switch(invoker.getAttribute("command")){
          case "show-modal":
            invoker.onclick = () =&gt; dialog.showModal();
            break;
          case "close":
            invoker.onclick = () =&gt; dialog.close();
            break;
        }
      });
    }
  });
}</code></pre><p>Note <code>&lt;dialog&gt;</code> <em>on its own </em>is supported on Safari, it’s the JS-less Invoker Commands that aren’t there yet. </p><h5 id="notes">Notes</h5>
<p>[1] The common <code>lightbox()</code> mistake is not rendering the form until the modal is opened — in extreme cases, not even loading <strong><em>forms2.min.js </em></strong>until it’s opened! This adds a user-facing delay. You should instead <mark>render the form immediately, hiding it using CSS,</mark> and show it when the lightbox is open:</p><pre><code class="language-css">.mktoForm {
   visibility: hidden;
   position: absolute;
}
.mktoModal .mktoForm {
   visibility: visible;
   position: static;
}</code></pre><p>But <code>lightbox()</code> should really be considered obsolete.</p><p>[2] My only frustration with <code>&lt;dialog&gt;</code> is its lack of an <em>automatically rendered </em>close button. In the screenshot above, the <code>&lt;button command="close"&gt;</code> is positioned using CSS <code>position: absolute; right: 0; top:0;</code>. Easy enough, but would be nice to do something like <code>&lt;dialog closebutton="true"&gt;</code> for a built-in ❎. Not as customizable but might help adoption for those who know zero CSS.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Microsoft “New Outlook” strictly enforces RFC 5545, potentially breaking ICS file generators (though not Marketo’s) ]]></title>
        <description><![CDATA[ Nobody asked Microsoft to fix this, though I guess it’s hard to object. Meanwhile, the rest of the iCalendar world is more generous. ]]></description>
        <link>https://blog.teknkl.com/microsoft-new-outlook-strictly-enforces-rfc-5545-potentially-breaking-ics-file-generators-not-marketo-or-agical/</link>
        <guid isPermaLink="false">68f05c941556b50001411b02</guid>
        <category><![CDATA[ agical ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Mon, 20 Oct 2025 01:35:52 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/10/specstuff.png" medium="image"/>
        <content:encoded><![CDATA[ <p>Violating the old <a href="https://www.joelonsoftware.com/2000/04/06/things-you-should-never-do-part-i/?ref=blog.teknkl.com" rel="noreferrer"><em>Never Rewrite From Scratch</em></a> rule (which, let’s be honest, is sporadically enforced) Microsoft created New Outlook in 2023.<em> </em>So far, it’s been a rocky rollout, with scant rewards for end users and mixed results for MOps.</p><p>On the bright side, email designers like the new rendering engine. You still need fallbacks for Classic Outlook, but more and more Microsoft shops will see your special effects. On the other side, the <mark>death of OFTs</mark> is pretty bad, but at least that’s a clean break.</p><p>More insidious are features ported over from Classic, <mark>but subtly “fixed” in a non-backward-compatible way</mark>. Case in point: Microsoft decided to rewrite their iCalendar (ICS) parser. The new parser hews <em>so tightly </em>to the standard, <a href="https://www.rfc-editor.org/rfc/rfc5545?ref=blog.teknkl.com" rel="noreferrer">RFC 5545</a>, that previously supported ICS files now don’t show the <em>Location </em>field:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/10/image-1.png" class="kg-image" alt="" loading="lazy" width="1513" height="955" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/10/image-1.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/10/image-1.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/10/image-1.png 1513w" sizes="(min-width: 720px) 720px"></figure><p>The specific area Microsoft fixed was the order of <code>VEVENT</code> properties and <code>VALARM</code> reminders. The <a href="https://www.rfc-editor.org/rfc/rfc5545.html?ref=blog.teknkl.com#:~:text=eventprop%20*alarmc" rel="noreferrer">standard is unequivocal</a> here. <mark>A valid VEVENT must have its <code>eventprop</code> entries (<code>DTSTAMP</code>, <code>ORGANIZER</code>, <code>SUMMARY</code>, <code>LOCATION</code>, etc.) before any <code>alarmc</code> components (<code>VALARM</code>)</mark>:</p><pre><code class="language-markup">   Format Definition:  A "VEVENT" calendar component is defined by the
      following notation:

       eventc     = "BEGIN" ":" "VEVENT" CRLF
                    eventprop *alarmc
                    "END" ":" "VEVENT" CRLF</code></pre><p>In other words, this ICS file is invalid since you can see the <code>VALARM</code> is above the <code>LOCATION</code> property:</p><pre>BEGIN:VCALENDAR
VERSION:2.0
METHOD:PUBLISH
PRODID:-//Microsoft Corporation//Outlook 11.0 MIMEDIR//EN
CALSCALE:GREGORIAN
BEGIN:VEVENT
DTSTAMP:20251016T041331Z
UID:20251016T041331Z-1266201208@ics.agical.io
DTSTART:20251112T190000Z
DTEND:20251112T230000Z
ORGANIZER:
SUMMARY:Logi Work Montréal
DESCRIPTION:Logi Work Montréal
<font color="darkpurple">BEGIN:VALARM
TRIGGER;VALUE=DURATION:-PT15M
DESCRIPTION:Alarm: Logi Work Montréal
ACTION:DISPLAY
END:VALARM</font>
LOCATION:Maison Alcan, 2100 Stanley Street
END:VEVENT
END:VCALENDAR
</pre><p>The correct order is like so:</p><pre>BEGIN:VCALENDAR
VERSION:2.0
METHOD:PUBLISH
PRODID:-//Microsoft Corporation//Outlook 11.0 MIMEDIR//EN
CALSCALE:GREGORIAN
BEGIN:VEVENT
DTSTAMP:20251016T041331Z
UID:20251016T041331Z-1266201208@ics.agical.io
DTSTART:20251112T190000Z
DTEND:20251112T230000Z
ORGANIZER:
SUMMARY:Logi Work Montréal
DESCRIPTION:Logi Work Montréal
LOCATION:Maison Alcan, 2100 Stanley Street
<font color="darkpurple">BEGIN:VALARM
TRIGGER;VALUE=DURATION:-PT15M
DESCRIPTION:Alarm: Logi Work Montréal
ACTION:DISPLAY
END:VALARM</font>
END:VEVENT
END:VCALENDAR</pre><p>Nevertheless, Outlook used to be fine with this. In fact, I <mark>can’t find a single ICS validator or library as strict as New Outlook!</mark> </p><p>From “reference libraries” like <a href="https://github.com/libical/libical?ref=blog.teknkl.com" rel="noreferrer">Libical</a>, <a href="https://kewisch.github.io/ical.js/validator.html?ref=blog.teknkl.com" rel="noreferrer">ical.js</a> and <a href="https://github.com/ical4j/ical4j?ref=blog.teknkl.com" rel="noreferrer">ical4j</a> to apparently homebrewed validators at <a href="https://icalendar.org/validator.html?ref=blog.teknkl.com" rel="noreferrer">iCalendar.org</a> and <a href="https://correctics.com/validate/?ref=blog.teknkl.com" rel="noreferrer">CorrectICS</a>, none care that <code>VALARM</code> is supposed to be after all the properties.<a href="#notes" rel="noreferrer">[1]</a>  They <em>should </em>care, as they would’ve flagged bad files long ago, but since every major calendar app prior to New Outlook was permissive, there was little pressure from their communities.</p><h2 id="the-good-news">The good news</h2><p>Marketo’s own Calendar {{my.tokens}} are unaffected — they don’t support reminders at all, so there’s nothing to be out of order. The Agical.io ICS generator has been patched as well. I’ve also checked files generated by Zoom and On24 and they’re fine.</p><h2 id="your-next-step">Your next step</h2><p>If you generate ICS files via 3rd-party apps/libraries or internal code, thoroughly test in New Outlook.</p><h5 id="notes">Notes</h5>
<p>[1] Google Calendar also doesn’t care about the order when importing. They likely use ical4j under the hood.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Returning false from an onSubmit() listener doesn’t stop submission — or do anything at all! ]]></title>
        <description><![CDATA[ Marketo forms use proper &lt;form&gt; and &lt;input&gt; elements, but they don’t submit like vanilla HTML forms. (Also: come on bro, test your code.) ]]></description>
        <link>https://blog.teknkl.com/returning-false-from-onsubmit-listener-doesnt-stop-submission/</link>
        <guid isPermaLink="false">68f1d5f2df815a0001dcdae3</guid>
        <category><![CDATA[ duplicates ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Fri, 17 Oct 2025 02:46:27 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/10/formsjs-1.png" medium="image"/>
        <content:encoded><![CDATA[ <p>A user had buggy 3rd-party JS causing forms to be submitted 2x. Unable to find the root cause in their stack of plugins, they tried a desperate fix: add an <code>onSubmit()</code> listener and <mark>stop submission if the listener runs a second time</mark>. </p><p>Like so:</p><pre><code class="language-javascript">MktoForms2.whenReady(function(readyForm){
   let stopFutureSubmissions = false;
   
   readyForm.onSubmit(function(submittingForm){
      if(stopFutureSubmissions) {
         return false;         
      }
      
      stopFutureSubmissions = true;
   });
                      
});</code></pre><p>Fine idea in theory, but <mark>that’s not how you stop form submission</mark>. The Marketo <code>onSubmit()</code> listener stack doesn’t care if you <code>return false</code> from every single listener! That is, it’s <em>not </em>like DOM event handlers where <code>return false</code> is interpreted as <a href="https://html.spec.whatwg.org/multipage/webappapis.html?ref=blog.teknkl.com#:~:text=If%20return%20value%20is%20false,%20then%20set%20event's%20canceled%20flag." rel="noreferrer">canceling the event</a>.<a href="#notes" rel="noreferrer">[1]</a></p><p>To stop submission, assuming you have complete control over the listener stack, simply set <code>form.submittable(false)</code>:</p><pre><code class="language-javascript">MktoForms2.whenReady(function(readyForm){
   
   readyForm.onSubmit(function(submittingForm){
      submittingForm.submittable(false);
   });
                      
});</code></pre><p>(If something is <em>trying </em>to submit the form multiple times, you’ve still gotta fix that at the source. This hack is a “leave in place for a week, seek outside help if you can’t find the cause” type thing.)</p><h5 id="notes">Notes</h5>
<p>[1] Not only is the Forms 2.0 API a custom API, even native <code>addEventListener()</code> doesn’t care about <code>return false</code>. Only the primitive <code>element.onsubmit = someFunction</code> method gives <code>return false</code> special meaning. </p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ A bunch of “Unsubscribe Email” activities for different emails, 5 minutes apart? That’s Gmail’s Manage Subscriptions feature ]]></title>
        <description><![CDATA[ Read between the (log) lines and you realize Gmail is hitting every List-Unsubscribe link, from first to last. ]]></description>
        <link>https://blog.teknkl.com/a-bunch-of-unsubscribe-email-activities-for-different-emails-5-minutes-apart-thats-gmails-manage-subscriptions-feature/</link>
        <guid isPermaLink="false">68d49b1bba3dfd000122b4a5</guid>
        <category><![CDATA[ List-Unsubscribe ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Wed, 01 Oct 2025 22:54:00 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/09/smtpstuff.png" medium="image"/>
        <content:encoded><![CDATA[ <p>You see a series of <em>Unsubscribe Email </em>activities, roughly 5 minutes apart, covering more or less all the emails someone received:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/09/image-23.png" class="kg-image" alt="" loading="lazy" width="1393" height="370" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/09/image-23.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/09/image-23.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/09/image-23.png 1393w" sizes="(min-width: 720px) 720px"></figure><p>Each one’s <em>Details </em>show they used the <code>List-Unsubscribe</code> header:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/09/image-22.png" class="kg-image" alt="" loading="lazy" width="1137" height="732" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/09/image-22.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/09/image-22.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/09/image-22.png 1137w" sizes="(min-width: 720px) 720px"></figure><p>Turns out that’s standard behavior of the newish <a href="https://support.google.com/mail/answer/15621070?ref=blog.teknkl.com" rel="noreferrer">Manage Subscriptions</a> feature in consumer Gmail.</p><p>When you go to Manage Subscriptions, Gmail shows you addresses that’ve sent enough marketing email to be trusted, which it then considers a “Subscription”:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/09/image-26.png" class="kg-image" alt="" loading="lazy" width="1183" height="358" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/09/image-26.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/09/image-26.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/09/image-26.png 1183w" sizes="(min-width: 720px) 720px"></figure><p>Clicking on a Subscription shows emails from that sender that are still in your mailbox:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/09/image-27.png" class="kg-image" alt="" loading="lazy" width="1192" height="742" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/09/image-27.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/09/image-27.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/09/image-27.png 1192w" sizes="(min-width: 720px) 720px"></figure><p>If you click <em>Unsubscribe </em>on the Subscriptions tab, <mark>Gmail visits all the <code>List-Unsubscribe</code> URLs in sequence</mark>, waiting 5 minutes between URLs to ensure it doesn’t exceed rate limits. This is mentioned, in a dumbed-down way, in the docs:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/09/image-21.png" class="kg-image" alt="" loading="lazy" width="1155" height="690" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/09/image-21.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/09/image-21.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/09/image-21.png 1155w" sizes="(min-width: 720px) 720px"></figure><p>Since every Marketo email has a unique <code>List-Unsubscribe</code> header — if it didn’t, we couldn’t track <em>Unsubscribe Email </em>at the asset level! — all emails are individually unsubscribed. This can mess with reporting, so look out for it.</p><p>Note neither side is misbehaving, although it does suggest a new SMTP header like <code>List-Unsubscribe-All</code> would be useful. (Good luck getting that through standards committees!)    </p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ A crazy way custom forms JS can cause “Submission failed,” with “checksum invalid” in the details ]]></title>
        <description><![CDATA[ The 20th field added to a Marketo form is special (yes, only the 20th!). Combine that obscure factoid with bad custom JS and you break the form. ]]></description>
        <link>https://blog.teknkl.com/a-crazy-way-broken-custom-js-can-cause-submission-failed-with-checksum-invalid-in-the-details/</link>
        <guid isPermaLink="false">68d94496ba86030001468b37</guid>
        <category><![CDATA[ hidden fields ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Tue, 30 Sep 2025 01:27:50 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/09/formsjs-1.png" medium="image"/>
        <content:encoded><![CDATA[ <p>You might think I’ve contrived a situation with an infinitesimal chance of happening in real life, just for the love of blogging. But <mark>this just happened</mark> to a user who posted on the Nation!  </p><p>So here’s the weirdest Marketo Forms factoid you may ever learn: <mark>if the 20th field added to a form has an empty <code>name</code>,</mark> you’ll get this generic error on submit:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/09/image-28.png" class="kg-image" alt="" loading="lazy" width="661" height="277" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/09/image-28.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/09/image-28.png 661w"></figure><p>And inspecting the HTTP 400 response shows:</p><pre><code class="language-javascript">{
  "message" : "checksum invalid",
  "error" : true,
  "errorCode" : "400"
}</code></pre><h2 id="hold-on-a-form-field-can-have-an-empty-name">Hold on, a form field can have an empty <code>name</code>???</h2><p><strong>Well, no.</strong> An <code>&lt;input name&gt;</code> and <code>&lt;input name=""&gt;</code> are both prohibited by the HTML standard. A native HTML <code>&lt;form&gt;</code> submit will not send values for those inputs.</p><p>But while Marketo forms use standard HTML <code>&lt;input&gt;</code> elements — a <em>very good thing,</em> I always take care to say! — the data that goes on the wire is assembled using JS. (Most modern forms do this.)</p><p>The Forms 2.0 API lets you add a hidden field with an empty name:</p><pre><code class="language-javascript">MktoForms2.whenReady(function(readyForm){
  readyForm.addHiddenFields({
    "" : "abc"
  });
});</code></pre><p>And that field will be sent to Marketo. Note empty query param names <em>are </em>allowed by the URL standard, so this is a perfectly legit <code>x-www-form-urlencoded</code> payload:</p><pre><code class="language-markup">field1=value1&amp;field2=value2&amp;=abc</code></pre><p>Again, a native, i.e. JS-less, <code>&lt;form&gt;</code> can’t create the above payload, but a JS-powered form can and the payload itself is legit, standards-wise. (Whether a given server parses it successfully is another question!<a href="#notes" rel="noreferrer">[2]</a>)</p><h2 id="only-custom-js-can-create-an-empty-name">Only custom JS can create an empty name</h2><p>You can’t have fields in Marketo whose Form Field Name is an empty string, so Form Editor will never trip you up like this. And <em>deliberately </em>adding a hidden form field with the name <code>""</code> would obviously be bizarre, since it would never store anything in Marketo. </p><p>Here’s the way an empty-named field got created without anyone knowingly doing so.</p><p>There’s 3rd-party UTM tracking JS on the site. That JS has a section where you’re supposed to map well-known UTM fields to business-specific Marketo Form Field Names: </p><pre><code class="language-javascript">const queryParamToFieldMap = {
  "utm_medium" : "utm_medium__c",
  "utm_campaign" : "utm_campaign_latest__c",
  "utm_source" : "utm_source_real__c"
};</code></pre><p>And then later in the code, that map is referenced to add hidden fields:</p><pre><code class="language-javascript">MktoForms2.whenReady(function(readyForm){
  readyForm.addHiddenFields({
    [queryParamToFieldMap.utm_medium] : stored.utm_medium,
    [queryParamToFieldMap.utm_campaign] : stored.utm_campaign,
    [queryParamToFieldMap.utm_source] : stored.utm_source
  });
});</code></pre><p>But when the user set up their field map, they didn’t realize they’d <em>literally set one field name to the empty string:</em></p><pre><code class="language-javascript">const queryParamToFieldMap = {
  "utm_medium" : "utm_medium__c",
  "utm_campaign" : "utm_campaign_latest__c",
  "utm_source" : ""
};</code></pre><p>That is, the code doesn’t set the name to the default when it sees the <code>""</code> or anything like that, it just uses <code>""</code> as entered.</p><p>The dynamic object key <code>[queryParamToFieldMap.utm_source]</code> is thus the empty string, so they inadvertently did the same as this:</p><pre><code class="language-javascript">MktoForms2.whenReady(function(readyForm){
  readyForm.addHiddenFields({
    "utm_medium__c" : "email",
    "utm_campaign_latest__c" : "2025-09-25-US-Quad-Complex",
    "" : "abc"
  });
});</code></pre><p>Now we know how you can get such a field on your form. Not so weird really. The weird part happens next.</p><h2 id="field-20-with-an-empty-name-is-special">Field #20 with an empty name is special</h2><p>You’re about to join a tiny club. Until last week even <em>I </em>didn’t know about this, and because it depends on a “perfect storm” of bad JS + bad luck, doubt anyone else knew.</p><p>To be clear, this isn’t a bug in Marketo Forms. As detailed above, your custom JS must have a bug that lets it add an empty-named field. Since such a field could never exist on the Marketo server side, you definitely screwed up first.😛 (Might be nice if <code>addHiddenFields</code> ignored empty names, but that’d be a belt-and-suspenders thing.)</p><p>Here we go.</p><p>Marketo form posts automatically include fields named <code>checksum</code> and <code>checksumFields</code> as a protective measure.<a href="#notes" rel="noreferrer">[1]</a> <code>checksum</code> is <mark>a SHA-256 hash of the <em>values </em>of the first 20 fields, joined by <code>|</code> characters</mark>. <code>checksumFields</code> is a comma-delimited list of the first 20 field <em>names</em>.</p><p>On the server side, checksum verification is easy: split <code>checksumFields</code> to get the names of fields used in the hash; get the values for those names and join with <code>|</code>; SHA-256 the <code>|</code>-delimited string; compare with the hash in <code>checksum</code>.</p><p>All good in normal use. But <mark>a trailing comma in <code>checksumFields</code> messes it up</mark>. It shouldn’t be possible to have a trailing comma, because it shouldn’t be possible to have an empty 20th field name! But indeed, when you have buggy JS, you can end up with:</p><pre><code class="language-markup">checksumFields=f1,f2,f3,f4,f5,f6,f7,f8,f9,fA,fB,fC,fD,fE,fF,fG,fH,fI,fJ,</code></pre><p>That gets split into 19 field names (<code>f1</code> through <code>fJ</code>), rather than 20. But the <code>checksum</code> was a SHA-256 of all 20 fields. Therefore the server-side and client-side hashes don’t match: <code>"message" : "checksum invalid"</code>.</p><h2 id="yep-multiple-things-have-to-go-wrong-at-once">Yep, multiple things have to go wrong at once</h2><p>There was code that could potentially add the field <code>""</code> <strong>and </strong>the end user did the thing that made <code>""</code> get added <strong>and </strong>that field was the 20th field added to the form.</p><p>Not sure what the takeaway should be but this was a nightmare to debug!</p><h5 id="notes">Notes</h5>
<p>[1] Other libraries use checksums/hashes to ensure data integrity (i.e. to detect bit errors) but Forms 2.0 uses checksums chiefly to discard “dumb” form posts, either from sources like 3rd-party webhooks or malicious bots that can’t do SHA-256. Presumably only the first 20 fields are digested to protect server resources; if you were aiming for data integrity, you’d digest all the fields and send the hash in <code>Content-Digest</code>.</p><p>[2] PHP’s native <code>parse_str</code> query string parser ignores query params with empty names. JS’s <code>URLSearchParams</code> parses them correctly. Your favorite package/framework/engine may or may not understand them.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Every Marketo field has a Friendly name, a Token name, a Form Field name, a REST API name, a SOAP API name, and a Velocity name — and they may all be different ]]></title>
        <description><![CDATA[ Don’t be scared, but don’t make assumptions about any 2 of these being the same. ]]></description>
        <link>https://blog.teknkl.com/every-marketo-field-has-a-friendly-name-a-token-name-a-form-field-name-a-rest-api-name-a-soap-api-name-and-a-velocity-name-and-they-may-all-be-different/</link>
        <guid isPermaLink="false">68d364f0ba3dfd000122b32e</guid>
        <category><![CDATA[ fields ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Wed, 24 Sep 2025 01:45:11 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/09/marketo_quirks.png" medium="image"/>
        <content:encoded><![CDATA[ <p>Feels weird to be writing about this at such a late date, but there’s misinformation going around, perhaps AI-influenced.</p><p>The spur for this post was the erroneous claim that Marketo Forms 2.0 uses REST API names as Form Field names. That is, if the REST API name is <code>email</code> the corresponding form field is <code>&lt;input name="email"&gt;</code>. </p><p>That’s clearly untrue, as you can see by inspecting the HTML of a basic form:   </p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/09/image-20.png" class="kg-image" alt="markup: <div class=&quot;mktoFormRow&quot;><div class=&quot;mktoFieldDescriptor mktoFormCol&quot; style=&quot;margin-bottom: 10px;&quot;><div class=&quot;mktoOffset&quot; style=&quot;width: 10px;&quot;></div><div class=&quot;mktoFieldWrap&quot;><label for=&quot;Email&quot; id=&quot;LblEmail&quot; class=&quot;mktoLabel mktoHasWidth&quot; style=&quot;width: 100px;&quot;><div class=&quot;mktoAsterix&quot;>*</div>Email Address:</label><div class=&quot;mktoGutter mktoHasWidth&quot; style=&quot;width: 10px;&quot;></div><input id=&quot;Email&quot; name=&quot;Email&quot; maxlength=&quot;255&quot; aria-labelledby=&quot;LblEmail InstructEmail&quot; type=&quot;email&quot; class=&quot;mktoField mktoEmailField mktoHasWidth&quot; style=&quot;width: 150px;&quot;><span id=&quot;InstructEmail&quot; tabindex=&quot;-1&quot; class=&quot;mktoInstruction&quot;></span><div class=&quot;mktoClear&quot;></div></div><div class=&quot;mktoClear&quot;></div></div><div class=&quot;mktoClear&quot;></div></div>" loading="lazy" width="1080" height="570" srcset="https://blog.teknkl.com/content/images/size/w600/2025/09/image-20.png 600w, https://blog.teknkl.com/content/images/size/w1000/2025/09/image-20.png 1000w, https://blog.teknkl.com/content/images/2025/09/image-20.png 1080w" sizes="(min-width: 720px) 720px"></figure><p>But even the response I gave on the Nation — <strong><em>No, it uses the SOAP API name </em></strong>— isn’t true 100.00% of the time. Don’t get me wrong: it’s true enough to get you through 99.99% of scenarios, and infinitely true-er than the false claim about the REST API name. It’s certainly true for the system field that holds the lead’s email address, i.e. the one with the default Friendly Name <code>Email</code>.</p><div class="kg-card kg-callout-card kg-callout-card-grey"><div class="kg-callout-emoji">💡</div><div class="kg-callout-text">Knowing all the different names a field can have makes it harder to refer to fields, right? We usually choose the Friendly Name, even if that one can change over time!</div></div><p>The truly-true statement is <strong><em>“Marketo uses the Form Field Name as the Form Field Name.”</em> </strong>The tautology illustrates that it’s possible for the <code>&lt;input name&gt;</code> attribute to be completely decoupled from any other metadata about the field.</p><h2 id="so-how-many-names-can-a-field-have">So how many names can a field have?</h2><p><mark>Each field has 6 possible names, each for a different context</mark>:</p><ul><li>the <strong>Friendly Name, </strong>used in Field Management and in (most) dropdown lists</li><li>the <strong>Token Name, </strong>used to output field values in Emails, LPs, webhooks, or flow steps</li><li>the <strong>Form Field Name, </strong>used as the <code>name</code> attribute on form elements </li><li>the <strong>REST API Name, </strong>used in the modern REST API</li><li>the <strong>SOAP</strong> <strong>API Name,</strong> used in the defunct SOAP API</li><li>the <strong>Velocity Name, </strong>used when you pull the field into the Velocity context (i.e. check the field in Script Editor)</li></ul><div class="kg-card kg-callout-card kg-callout-card-yellow"><div class="kg-callout-emoji">💡</div><div class="kg-callout-text">Honorable mention goes to <b><strong style="white-space: pre-wrap;">List Import Aliases, </strong></b>which allow CSVs to use different header fields if your sources can’t get their act together.😛 We’ll ignore those for now.</div></div><p>Some of those names may be identical for a given field. For example, the built-in <code>Email</code> field has:</p><ul><li>Friendly Name <code>Email</code></li><li>Token Name <code>Email Address</code> (i.e. <code>{{lead.Email Address}}</code>)</li><li>REST API Name <code>email</code></li><li>Form Field Name <code>Email</code></li><li>SOAP API Name <code>Email</code></li><li>Velocity name <code>Email</code> (i.e. <code>$lead.Email</code>)</li></ul><p>So there are only 3 unique <em>values </em>across the different types. But sorry to say, you must consider that a coincidence. As noted above, it’s not 100.00% safe to assume the Form Field Name is the SOAP API Name.</p><p>Check out this interesting exception, the built-in (and, to be fair, largely unused) field with the Friendly Name <code>Marketo Data.com ID</code>:</p><ul><li>Friendly Name <code>Marketo Data.com ID</code></li><li>Token Name <code>Jigsaw Contact ID</code> (<code>{{lead.Jigsaw Contact ID}}</code>)</li><li>REST API Name <code>jigsawContactId</code></li><li>Form Field Name <code>JigsawContactId</code></li><li>SOAP API Name <code>Marketo Jigsaw Contact Id</code></li><li>Velocity Name: <code>Marketo Jigsaw Contact Id</code> (<code>$lead["Marketo Jigsaw Contact Id"]</code>)</li></ul><p>Not only does this field have 5 unique values, the Velocity Name is notable because when you drag the field from the tree in Script Editor, Marketo inserts <code>${lead.Marketo Jigsaw Contact Id}</code>. This is actually invalid VTL because spaces aren’t allowed in dot-property format, just like in JavaScript. You’ll get a ParseException if you leave it like that.<a href="#notes" rel="noreferrer">[1]</a></p><p>Also notable: the SOAP API Name has spaces. That means the SOAP API Name and Form Field Name <em>must </em>be different. Not that an HTML <code>&lt;input name&gt;</code> can’t have spaces, standards-wise — it certainly can — but an HTML <code>&lt;input id&gt;</code> can’t have spaces and Forms 2.0 uses the same value for <code>name</code> and <code>id</code>. (This is good, by the way.)</p><h2 id="don%E2%80%99t-be-scared-just-treat-each-one-separately">Don’t be scared, just treat each one separately</h2><p>This isn’t a big deal, really. Just give up your assumptions about the connection between names.</p><ul><li>When you use the token browser, Marketo always inserts the right Token Name. Don’t copy-and-paste the Friendly Name.</li><li>Inspecting the HTML <code>&lt;form&gt;</code> or running <code>getValues()</code> shows the Form Field Name. Don’t copy-and-paste the SOAP API Name. (Or go ahead and paste it because it’s the same 99.99% of the time, but check there first if anything goes wrong!)</li><li>When you drag the field onto the Script Editor canvas, Marketo always inserts the right Velocity Name.</li><li>The REST API Name is easy to get from Field Management or the <em>Describe Lead </em>API endpoint. Don’t assume it’s the same as the Friendly Name, even if the Friendly Name has an API-compatible format (no spaces or non-ASCII characters).</li></ul><h5 id="notes">Notes</h5>
<p>[1] Instead, you need to change it to <code>$lead["Marketo Jigsaw Contact Id"]</code> or <code>$lead.get("Marketo Jigsaw Contact Id")</code>.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ A global trigger campaign to alert on interesting Program Status changes, with the new Status included in the alert ]]></title>
        <description><![CDATA[ Marketo recently added a bunch of new {{trigger.tokens}} that can help reduce the number of nearly-redundant trigger campaigns, always a good thing.

Some of the tokens are relatively “raw,” though: they output unique IDs, not friendly names. Massaging those into values suitable for people like sales owners takes ]]></description>
        <link>https://blog.teknkl.com/a-global-trigger-campaign-to-alert-on-interesting-program-status-changes-with-the-new-status-included-in-the-alert/</link>
        <guid isPermaLink="false">68d1cff4321e690001ca5037</guid>
        <category><![CDATA[ velocity ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Tue, 23 Sep 2025 14:06:00 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/09/token_tricks_v2.png" medium="image"/>
        <content:encoded><![CDATA[ <p>Marketo recently added a bunch of new <code>{{trigger.tokens}}</code> that can help reduce the number of nearly-redundant trigger campaigns, always a good thing.</p><p>Some of the tokens are relatively “raw,” though: they output <em>unique IDs</em>, not friendly names. Massaging those into values suitable for people like sales owners takes a little work.</p><h2 id="going-global-to-avoid-local-bloat">Going global to avoid local bloat</h2><p>We had a request the other day for alerts on <em>Program Status is Changed, </em>where the <em>New Status </em>is in a list of interesting statuses and the program is <mark>any program in the instance</mark>. The alert email also has to <mark>include the new status</mark>.</p><p>Such a campaign can of course be added at the program level:</p><ul><li>trigger on <em>Program Status is Changed</em></li><li><em>c</em>onstrain on <em>Program [is] &lt;this program&gt;, New Status [is] &lt;list&gt;</em></li><li>include <code>{{member.status}}</code> in the email</li></ul><p>The problem is you’d have to do this for every program, forever. Nobody wants that.</p><p>Instead, we wanted a single global campaign triggering on <em>Program Status is Changed, Program [is any]:</em></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/09/image-18.png" class="kg-image" alt="" loading="lazy" width="1452" height="250" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/09/image-18.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/09/image-18.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/09/image-18.png 1452w" sizes="(min-width: 720px) 720px"></figure><p>The goal, then, is to figure out how to get the equivalent of <code>{{member.status}}</code>, since our campaign lives outside the triggering program(s).</p><p>Aha! One new token is <code>{{trigger.New Status ID}}</code>, a token that’s available in a <em>Program Status is Changed </em>flow. Sounds perfect, right? But the catch is <code>{{trigger.New Status ID}}</code> returns the unique <em>numeric identifier </em>of the status, not its <em>name</em>. And sales doesn’t understand Program Status 113 or Program Status 221!</p><h2 id="start-thinking-about-velocity-maps">Start thinking about Velocity maps </h2><p>One ultra-powerful use for Velocity is to map unfriendly names/IDs to friendly names, like so:</p><pre><code class="language-velocity">#set( $statusNameMap = {
  10  : "Advertising &gt; Filled Form",
  56  : "Advertising &gt; Downloaded",
  224 : "Advertising &gt; Downloaded High-Value Asset",
  168 : "Content &gt; Downloaded",
  221 : "Content &gt; Downloaded High-Value Asset",
  90  : "Content Syndication (PPL) &gt; Downloaded",
  225 : "Content Syndication (PPL) &gt; Downloaded High-Value Asset",
  226 : "Content Syndication (PPL) &gt; Filled Form",
  4   : "Webinar &gt; Registered",
  6   : "Webinar &gt; Attended",
  7   : "Webinar &gt; On-Demand",
  210 : "Website &gt; Filled Form",
  211 : "Website &gt; Downloaded", 
  231 : "Website &gt; Downloaded High-Value Content"
} )</code></pre><p>(Some call these as “lookup tables” but I prefer to call them “maps,” since they’re literally Java LinkedHashMaps.)</p><p>If we had such a map, we could grab the friendly name with <code>$statusNameMap.get($programStatusId)</code>.</p><p>There’s no <em>official </em>way to get the Program Status <strong>ID </strong>that currently<a href="#notes" rel="noreferrer">[1]</a> corresponds to a given Program Status <strong>Name</strong>. You can’t get it via the REST API and there isn’t a formal export feature. But you <em>can </em>use Dev Tools to get it from the Marketo UI.<a href="#notes" rel="noreferrer">[2]</a> Right-click and inspect the <code>&lt;input&gt;</code> for the Program Status Name:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/09/image-19.png" class="kg-image" alt="" loading="lazy" width="1285" height="880" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/09/image-19.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/09/image-19.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/09/image-19.png 1285w" sizes="(min-width: 720px) 720px"></figure><p>Then look in the HTML for the adjacent <code>&lt;input type="hidden"&gt;</code>. Its <code>value</code> attribute is the Program Status ID.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/09/image-15.png" class="kg-image" alt="" loading="lazy" width="882" height="612" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/09/image-15.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/09/image-15.png 882w" sizes="(min-width: 720px) 720px"></figure><p>OK, great! Now we can build that <em>ID</em> ⮕ <em>Name </em>map in a Velocity token <code>{{my.Program Status Name Map}}</code>. But there’s one more hurdle.</p><h2 id="triggertokens-aren%E2%80%99t-automatically-accessible-in-velocity"><code>{{trigger.tokens}}</code> aren’t automatically accessible in Velocity</h2><p>So we have <code>$statusNameMap</code> ready to go. But how do we look up our <code>{{trigger.New Status ID}}</code> in the map, since <code>{{trigger.tokens}}</code> aren’t present in the Velocity context?</p><p>Well, they’re actually <em>present, </em>but they’re tricky to get to. Each one is a property of the Velocity <code>$trigger</code> object, but:</p><ul><li>it’s converted to upper case</li><li>spaces are converted to underscores</li><li>it’s prefixed with <code>v</code></li><li>it’s suffixed with <code>_</code> and an unpredictable number</li></ul><p>For example, <code>{{trigger.New Status ID}}</code> can be <code>$trigger.vNEW_STATUS_ID_99</code> in Velocity. But the <code>99</code> can’t be known in advance.</p><p>Not to worry. We can’t directly <code>get("vNEW_STATUS_ID_&lt;nnnn&gt;")</code> because we don’t know the suffix, but we can loop over <code>$trigger</code> until a key matches our prefix. </p><p>That happens in the Velocity token <code>{{my.Get Program Status Name}}</code>:</p><pre><code class="language-velocity">#foreach( $entry in $trigger.entrySet() )
#set( $key = $entry.getKey() )
#set( $value = $entry.getValue() )
#if( $key.startsWith("vNEW_STATUS_ID_") &amp;&amp; !$display.alt($value,"").isEmpty() )
#set( $newProgramStatusId = $convert.toInteger($value) )
#break
#end
#end
#set( $newProgramStatusName = $statusNameMap.get($newProgramStatusId) )</code></pre><p>The final piece is the one-line Velocity token <code>{{my.New Program Status Name}}</code> to output the value (remember, best practice is separate “calculating” and “output” tokens):</p><pre><code class="language-velocity">${newProgramStatusName}</code></pre><h2 id="wait-one-more-thing">Wait, one more thing</h2><p><mark>The <code>{{trigger.token}}</code> must be <em>used </em>in the email to be accessible in Velocity</mark>. Sounds like a Catch-22, but you can drop it inside an HTML comment!<a href="#notes" rel="noreferrer">[3]</a></p><h2 id="sample-alert-email">Sample alert email</h2><pre><code class="language-markup">Subject: One of your contacts just had an interesting Program Status Change!</code></pre><pre><code class="language-markup">{{my.Program Status Name Map}}{{my.Get Program Status Name}}
Name: {{lead.Full Name}}
Email: {{lead.Email Address}}
Company: {{company.Company Name}}
Title: {{lead.Job Title}}
Phone: {{lead.Phone Number}}
Program Name: {{trigger.name}}
New Program Status: {{my.New Program Status Name}} ({{trigger.New Status ID}})
Salesforce Record Link: https://exampleco.my.salesforce.com/{{lead.SFDC Id}}</code></pre><p>The “map” token <code>{{my.Program Status Name Map}}</code> and “calculate” token <code>{{my.Get Program Status Name}}</code> must be included somewhere <em>before </em>the “output” token <code>{{my.New Program Status Name}}</code>.</p><h5 id="notes">Notes</h5>
<p>[1] Yes, it’s technically possible for the name to change (that’s why the Program Status ID exists). And of course new statuses may be added. You’ll have to modify the <code>$statusNameMap</code> if there’s a relevant change to <em>Admin » Tags » Channels</em>. </p><p>[2] Alternately, look at the POST response for <code>https://app-&lt;nnnn&gt;.marketo.com/marketingEvent/getDescriptorValue</code>. This is left as an exercise for the reader.</p><p>[3] Or between two partial Velocity tokens (<code>#*</code><em> and </em><code>*#</code>) to wrap it in a block comment. This has the advantage of <em>removing it completely </em>from the rendered email, both HTML and Text versions.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Flip a Marketo form field between “Required” and “Not Required” at runtime ]]></title>
        <description><![CDATA[ Build advanced form behaviors with this bit of JS. ]]></description>
        <link>https://blog.teknkl.com/flip-a-marketo-form-field-between-required-and-not-required-at-runtime/</link>
        <guid isPermaLink="false">68bdccbfe744dd00012043e4</guid>
        <category><![CDATA[ forms js ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Thu, 18 Sep 2025 22:27:00 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/09/formsjs.png" medium="image"/>
        <content:encoded><![CDATA[ <p>Native Visibility Rules and Progressive Profiling pack immense power. But one thing they can’t do is change whether a field is required<em>: </em>if a field is displayed per VR and/or ProgPro rules, it simply picks up the <em>Required </em>setting from Form Editor.</p><p>This means you need JS for more advanced behaviors. Say <em>Country </em>should be optional for Home users: </p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/09/image-5.png" class="kg-image" alt="" loading="lazy" width="1038" height="642" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/09/image-5.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/09/image-5.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/09/image-5.png 1038w" sizes="(min-width: 720px) 720px"></figure><p><em>Country </em>and <em>Company </em>are required for Professional and Enterprise. while <em>Number of Employees </em>is displayed (via native VRs) but optional for Professional:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/09/image-2.png" class="kg-image" alt="" loading="lazy" width="982" height="790" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/09/image-2.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/09/image-2.png 982w" sizes="(min-width: 720px) 720px"></figure><p>And finally, Enterprise users must provide <em>Country, Company </em>and <em>Number of Employees:</em></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/09/image-6.png" class="kg-image" alt="" loading="lazy" width="1027" height="790" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/09/image-6.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/09/image-6.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/09/image-6.png 1027w" sizes="(min-width: 720px) 720px"></figure><h2 id="enter-managefieldrequirement">Enter <code>manageFieldRequirement()</code></h2><p>Here’s a little JS function that lets you achieve such special effects:</p><pre><code class="language-javascript">/**
 * Programmatically require/unrequire a Marketo form field
 * @author Sanford Whiteman
 * @version v1.2 2025-08-25
 * @license Hippocratic 3.0: This license must appear with all reproductions of this software.
 *
 */
function manageFieldRequirement(mktoForm, fieldName, userOptions = {}){    
  const defaultOptions = {
    require: false,
    alsoFlipVisibility: false,
    validationMessage: undefined
  };

  const options = Object.assign(defaultOptions, userOptions);

  const formJq = mktoForm.getFormElem();
  const fieldJq = formJq.find(`[name="${CSS.escape(fieldName)}"]`);

  if(!fieldJq.length) {
    return;
  }

  const rowJq = fieldJq.closest(".mktoFormRow");
  const colJq = fieldJq.closest(".mktoFormCol");
 
  const rowColCount = rowJq.find(".mktoFormCol").length; 
  const flippableJq = rowColCount === 1 ? rowJq : colJq;  
  
  const wrapperJq = fieldJq.closest(".mktoFieldWrap");
  const descriptorJq = fieldJq.closest(".mktoFieldDescriptor");

  if(options.require){
    fieldJq.attr("aria-required","true");
    fieldJq.addClass("mktoRequired");
    wrapperJq.addClass("mktoRequiredField");
    descriptorJq.data("mktoFieldDescriptor").required = true;

    if(options.alsoFlipVisibility){
      flippableJq.show();
    }    
  } else {
    fieldJq.removeAttr("aria-required");
    fieldJq.removeClass("mktoRequired");
    wrapperJq.removeClass("mktoRequiredField");
    descriptorJq.data("mktoFieldDescriptor").required = false;

    if(options.alsoFlipVisibility){
      flippableJq.hide();
    }    
  }
  
  if(options.validationMessage){
    descriptorJq.data("mktoFieldDescriptor").validationMessage = options.validationMessage;
  }    
  
}</code></pre><p>(With platform features these days, you don’t need jQuery to manipulate the DOM. But here we specifically need jQuery <code>data</code>, so decided to embrace the beast.)</p><h2 id="usage">Usage</h2><p><code>manageFieldRequirement()</code> takes 3 params:</p><ul>
<li><code>mktoForm</code> - a Marketo Forms 2.0 form object, e.g. the argument passed to a <code>whenReady()</code>  listener</li>
<li><code>fieldName</code> - the form field name, a.k.a. SOAP API name</li>
<li><code>userOptions</code> - an object with a few properties:
<ul>
<li><code>require</code> (Boolean, default <code>false</code>) - require the field or un-require it</li>
<li><code>alsoFlipVisibility</code> (Boolean, default <code>false</code>) - also show the field if required/hide if unrequired</li>
<li><code>validationMessage</code> (String, optional) - custom validation message to swap in</li>
</ul>
</li>
</ul>
<h2 id="example-get-the-behavior-shown-in-the-screenshots-above">Example: Get the behavior shown in the screenshots above</h2><p>Inside a Forms 2.0 <code>whenReady()</code> listener, add a DOM <code>change</code> listener. Then do advanced management of <code>Country</code> and <code>Company</code> fields:</p><pre><code class="language-javascript">MktoForms2.whenReady(function(readyForm){
  const formEl = readyForm.getFormElem()[0];
  
  formEl.addEventListener("change",function(e){
    const currentValues = readyForm.getValues();
    const requestedSubscriptionType = currentValues["SubscriptionType"];
    
    if( !requestedSubscriptionType || requestedSubscriptionType === "Home" ) {
      manageFieldRequirement(readyForm, "Country", { require: false });    
      manageFieldRequirement(readyForm, "Company", { require: false });    
    } else {
      manageFieldRequirement(readyForm, "Country", { require: true, validationMessage: "Please provide Country for Pro and Enterprise accounts." });
      manageFieldRequirement(readyForm, "Company", { require: true, validationMessage: "Company is required for Pro and Enterprise accounts." });
    }

    if( !requestedSubscriptionType || requestedSubscriptionType === "Home" || requestedSubscriptionType === "Professional" ) {
      manageFieldRequirement(readyForm, "NumberOfEmployees", { require: false });    
    } else {
      manageFieldRequirement(readyForm, "NumberOfEmployees", { require: true, validationMessage: "Number of employees is required for Enterprise accounts." });
    }

  });
  
  // initialize state
  formEl.dispatchEvent(new Event("change"));
  
});</code></pre><p>N.B. The <code>change</code> listener works even when Visibility Rules are also managing the field because <mark>new HTML element(s) are injected synchronously by the VR code, <em>before </em>the event bubbles up to the <code>&lt;form&gt;</code>.</mark> This is good!</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ NYC MUG in-person talk and meetup: Thursday, 9/25 ]]></title>
        <description><![CDATA[ See you there? ]]></description>
        <link>https://blog.teknkl.com/nyc-mug-in-person-talk-and-meetup-thursday-25/</link>
        <guid isPermaLink="false">68ccb4b36ffc400001b1632a</guid>
        <category><![CDATA[ NYC MUG ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Thu, 18 Sep 2025 22:26:43 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/09/liveevents.png" medium="image"/>
        <content:encoded><![CDATA[ <p>We’re recommitting to in-person meetups for late 2025 and 2026. First up: Thursday, September 25th from 3-4pm at Adobe’s Union Square office (100 Fifth Avenue).</p><p>Webflow’s Guy Yasif will present <strong><em>The Strategic CMO: Driving Personalization and Performance with Marketo and AI-Powered GTM. </em></strong>Just as important, I’ll be around afterward to chat about whatever you like.<strong>😀</strong></p><p>RSVP <a href="https://mugs.marketo.com/events/details/marketo-new-york-mug-presents-the-strategic-cmo-driving-personalization-and-performance-with-marketo-and-ai-powered-gtm/?ref=blog.teknkl.com" rel="noreferrer">here</a>.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Extract “Firstname B. Lastname” from “firstname.b.lastname@example.com” using Velocity (with caveats) ]]></title>
        <description><![CDATA[ Consider this a stopgap measure while you’re waiting for First and Last to be properly mapped. ]]></description>
        <link>https://blog.teknkl.com/extract-firstname-b-lastname-from-firstname-b-lastname-at-example-com-using-velocity/</link>
        <guid isPermaLink="false">68c66e4bab54cd0001bb0598</guid>
        <category><![CDATA[ velocity ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Sun, 14 Sep 2025 18:15:10 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/09/velocity-marketo-logo.png" medium="image"/>
        <content:encoded><![CDATA[ <p>Let me start by noting this technique <mark>is for addresses @ <em>your email domain</em>, not for lead email addresses.</mark> If you forgot to require <em>First Name </em>and <em>Last Name </em>on a lead gen form<em>, </em>complete their profile responsibly. That’s not what this is for!</p><p>Sometimes, due to technical gaps, you only have the email address of a sales owner or other internal user, but you need a friendly name for the From: header or signature block.</p><p>If your company used <em>100% predictable </em>logic to generate the address, you can (fairly) safely synthesize a friendly name while waiting for CRM to do a proper field mapping.</p><p>That logic is:</p><blockquote>
<p>Given a display name where (1) <em>all characters are valid in an email address</em> and (2) <em>all words are in title case</em>, convert spaces to dots and append the domain.</p>
</blockquote>
<p>For example, the name <code>Sandy-Jo B. O'Shelley</code> is converted to the email address <code>sandy-jo.b.o'shelley@example.com</code>. <em>If </em>an address was generated that way, this Velocity code —</p><pre><code class="language-velocity">#set( $emailField = $lead.YourEmailField )
## break down left-hand-side@right-hand-side
#set( $lhs = $emailField.substring(0,$emailField.lastIndexOf("@")) )
## split the lhs on the dot
#set( $lhsParts = $lhs.split("\.") )
#set( $displayableParts = [] )
#foreach( $part in $lhsParts )
#set( $displayable = "" )
## split on word break, title case each word, re-join
#foreach( $word in $part.split("\b") )
#set( $displayable = $displayable + $display.capitalize($word.toLowerCase() ) )
#end
## assume single letter is initial
#if( $displayable.length().equals(1) )
#set( $displayable = $displayable + "." )
#end
#set( $void = $displayableParts.add($displayable) )
#end
${display.list( $displayableParts, " " )}</code></pre><p>— will extract the original name <code>Sandy-Jo B. O'Shelley</code>. </p><h2 id="caveats">Caveats</h2><ul><li>Repeating the warning above, this isn’t for lead email addresses. Please don’t use it for that purpose.</li><li>Some mailservers don’t support — or perhaps <em>are believed to not support — </em>ASCII characters like the quote/apostrophe <code>'</code> in mailbox names, even though that’s a perfectly valid character in the SMTP standard. In those cases the original <code>O’Shelley</code> would map to <code>oshelley</code> in the email address. You cannot possibly know that happened, so the result will be <code>Oshelley</code>.</li><li>You can’t know if accents were removed while transforming the result into an ASCII-only value. <code>Leon</code> and <code>Léon</code> will both map to <code>leon</code>.</li><li>You can’t know if someone’s name should use mixed case, as <code>de la Cruz</code> and <code>De La Cruz</code> will both map to <code>de.la.cruz</code>. Same for <code>McCardle</code> mapping to <code>mccardle</code>.</li><li>All non-Latin-1 characters and/or anglicized names can’t be recovered. That’s why it’s noted above that all characters must be valid in an email address!</li><li>In some contexts, a single letter isn’t an “initial” in the western style. So <code>subramanian.m@example.com</code> should be rendered as <code>Subramanian M</code>. The flipside is some two-letter names <em>are </em>properly rendered as initials, like <code>Ma.</code>. You can’t know what’s right just by looking at the email address.</li><li><mark>There’s no substitute for knowing the person’s actual display name!</mark></li></ul> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Custom Object tip: create a duplicate field if you’ll need to constrain a Date/DateTime to specific ranges ]]></title>
        <description><![CDATA[ Reporting and filtering requirements can force a weird CO schema, but it works. ]]></description>
        <link>https://blog.teknkl.com/custom-object-tip-create-a-duplicate-date-datetime-field-if-you-need-date-ranges/</link>
        <guid isPermaLink="false">679d995cca68b000017528ec</guid>
        <category><![CDATA[ custom objects ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Mon, 25 Aug 2025 00:45:24 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/02/marketo_quirks.png" medium="image"/>
        <content:encoded><![CDATA[ <p>You have a robust multi-subscription model using Marketo Custom Objects: each Subscription+Person combo has robust metadata about when somebody subscribed/unsubscribed, why, and how.</p><p>The boss goes, <strong><em>“Can we get a scheduled report of people who subscribed to any newsletter in the past month?” </em></strong>You know just what to do:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/08/image-5.png" class="kg-image" alt="" loading="lazy" width="1176" height="495" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/08/image-5.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/08/image-5.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/08/image-5.png 1176w" sizes="(min-width: 720px) 720px"></figure><p>Then they throw in <strong><em>“Can we compare that to the 2 prior months?”</em></strong></p><p>You almost say, <em><strong>“Sure” </strong></em>but decide to play around a bit. Then the <em>uh-oh </em>hits you. First, the <code>between</code> operator won’t work because it requires hard-coded dates, and this is to be a dynamic Smart List:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/08/image.png" class="kg-image" alt="" loading="lazy" width="1629" height="537" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/08/image.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/08/image.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1600/2025/08/image.png 1600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/08/image.png 1629w" sizes="(min-width: 720px) 720px"></figure><p>Next, you try to combine <code>in past before</code> and <code>in past</code> (like you would with person fields): </p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/08/image-1.png" class="kg-image" alt="" loading="lazy" width="1629" height="780" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/08/image-1.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/08/image-1.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1600/2025/08/image-1.png 1600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/08/image-1.png 1629w" sizes="(min-width: 720px) 720px"></figure><p>But that’s got a fatal flaw: <mark>each filter may be looking at a different CO record, i.e. a different newsletter!</mark></p><p>This Smart List fetches people who: </p><p>(a) have at least one <em>Subscriber </em>record with <em>Subscribed At </em>earlier than 1 month ago<br><strong>AND</strong><br>(b) have at least one <em>Subscriber </em>record with a <em>Subscribed At </em>in the past 3 months</p><p>This could include <mark>people who</mark> <mark>subscribed to a newsletter within the last month, as long as they subscribed to <em>another </em>newsletter<em> </em>more than a month ago</mark>. Filter 1 and Filter 2 aren’t looking at the same record, they’re looking at the whole CO list. Make sense?</p><h2 id="prepare-for-this">Prepare for this</h2><p>You can prepare your COs for this kind of query by creating another field, also a DateTime, that holds the exact same value:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/08/image-4.png" class="kg-image" alt="" loading="lazy" width="1195" height="391" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/08/image-4.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/08/image-4.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/08/image-4.png 1195w" sizes="(min-width: 720px) 720px"></figure><p>Then you can use <code>in past before</code> and <code>in past</code> against the same CO record:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/08/image-2.png" class="kg-image" alt="" loading="lazy" width="1617" height="552" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/08/image-2.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/08/image-2.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1600/2025/08/image-2.png 1600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/08/image-2.png 1617w" sizes="(min-width: 720px) 720px"></figure> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ You should probably be checking &quot;isTrusted&quot; in postMessage() listeners ]]></title>
        <description><![CDATA[ The accepted way to secure postMessage() has a bypass you likely don’t know about, even if it’s not the hugest deal on its own. ]]></description>
        <link>https://blog.teknkl.com/you-should-probably-be-checking-istrusted-in-messageevent-listeners/</link>
        <guid isPermaLink="false">68a5f2132d5a4b0001645019</guid>
        <category><![CDATA[ DOM ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Fri, 22 Aug 2025 02:37:05 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/08/js.png" medium="image"/>
        <content:encoded><![CDATA[ <p><a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage?ref=blog.teknkl.com" rel="noreferrer"><code>window.postMessage</code></a> and corresponding <a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/message_event?ref=blog.teknkl.com" rel="noreferrer"><code>window.onmessage</code></a> listeners enable secure communication between cross-origin windows, including IFRAMEs. The <code>postMessage</code> API is ubiquitous, used by popular JS widgets like media players (YouTube, Spotify), meeting schedulers (Calendly), chat popups, and forms<a href="#notes" rel="noreferrer">[1]</a>.</p><p>Code in the main window listens for messages from these widgets and relays data to analytics dashboards, MAPs, and so on. To make the “secure” part a reality, it’s <a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage?ref=blog.teknkl.com#the_dispatched_event" rel="noreferrer">standard</a> to check the <code>origin</code> property of every message to ensure it came from a known child window:<a href="#notes" rel="noreferrer">[2]</a></p><pre><code class="language-javascript">window.addEventListener("message", function(e){
  if( e.origin !== "https://video.example.ly" ) return;

  // do lots of special stuff with e.data, like sending a Munchkin
  // "clickLink" event
});</code></pre><p><mark>Browsers guarantee the <code>origin</code> of MessageEvents sent via <code>postMessage</code> will be the <code>document.location.origin</code> of the sending window.</mark> Can’t get around it: if an IFRAME at <code>https://pages.example.com/some/lp.html</code> uses <code>window.parent.postMessage</code>, the parent will always see the <code>origin</code> value <code>"https://pages.example.com"</code>.</p><h2 id="but-that%E2%80%99s-not-the-only-way-to-send-a-messageevent">But that’s not the only way to send a MessageEvent</h2><p>I’ve been choosing my words carefully. 😉</p><p>All the above is 100% true <strong>if you use <code>postMessage</code></strong>.<em> </em>But if you can directly access a <code>window</code> object — as can non-worker code running on the same origin as the target <code>window</code> — you can also do this:</p><pre><code class="language-javascript">const me = new MessageEvent("message", { 
  origin: "https://video.example.ly",
  data: { whatever: "you want" }
});
window.dispatchEvent(me);</code></pre><p>The listening <code>window</code> will see the same <code>origin</code> as when events truly come from the IFRAME. Which means anything the listener typically does with the <code>e.data</code>, you’ve just silently fooled it into doing with your data.</p><p>Yikes? Okay, soft yikes. There are factors that limit the severity of this vulnerability, so while it’s something you should fix (and it’s easy!), no need to panic. </p><h2 id="limiting-factors">Limiting factors</h2><p>(1) Malicious code must be able to access the <code>window</code> object that’s listening for messages. An IFRAME on a different site can’t do this, you’ll get:</p><pre><code class="language-markup ">Blocked a frame with origin "https://malicious.example.net" from accessing a cross-origin frame.</code></pre><p>(2) CSP policies may only allow 3rd-party JS once it’s been thoroughly audited. (But let’s be honest, in the marketing world where anyone can drop code into GTM or Tealium, this isn’t happening.)</p><p>(3) <em>If </em>code can access the window, countless other attacks can be mounted as well.<a href="#notes" rel="noreferrer">[3]</a> (Running untrusted code in anything but a cross-origin worker or sandboxed IFRAME is always risky, though roughly everybody does it!)</p><p>What distinguishes this vulnerability is its silence: it’s not overwriting functions or intercepting network requests. It’s just quietly penetrating something you <em>thought </em>was a security measure, in a by-design-but-barely-documented way.   </p><h2 id="the-fix">The fix</h2><p>Check the <a href="https://developer.mozilla.org/en-US/docs/Web/API/Event/isTrusted?ref=blog.teknkl.com" rel="noreferrer"><code>isTrusted</code></a> property in addition to <code>origin</code>:</p><pre><code class="language-javascript">window.addEventListener("message", function(e){
  if( !e.isTrusted || e.origin !== "https://video.example.ly" ) return;

  // do lots of special stuff with e.data, like sending a Munchkin
  // "clickLink" event
});</code></pre><p><code>isTrusted</code> is <code>false</code> for MessageEvents that were manually constructed and dispatched (unlike the MessageEvents automatically constructed by <code>postMessage</code>).</p><p>The side effect, by definition, is you can’t roll + send your own MessageEvents anymore. Not sure why that would ever be critical, but I’m open to suggestions in the comments.</p><h5 id="notes">Notes</h5>
<p>[1] While Marketo forms technically use <code>postMessage</code> under the hood — they submit via a Marketo-hosted IFRAME for reliable transport — they’re pretty off-topic for this discussion. <mark>The visible Marketo form is not an IFRAME</mark>, it’s in the top-level DOM (which is good!). </p><p>[2] Technically, this code checks for an <code>origin</code>, not for a unique window. You might have more than one window on the same <code>origin</code>, each sending different messages, in which case you’d check the <code>source</code> property too. But <code>source</code> can <em>also </em>be forged in a manually constructed MessageEvent! So there’s no situation where <code>source</code> alone would fix this vulnerability.</p><p>[3] Even though code in global scope can be wildly destructive, that doesn’t mean it’d be easy to exploit the <em>same </em>vulnerability that might be opened here, let alone exploit it silently.</p><p>For example, imagine the event listener is added in a <code>&lt;script type="module"&gt;</code> that’s always loaded before your tag manager loads a potential exploit. The malicious code can’t simply remove that listener, nor stop it from firing, nor access its code at runtime. If there’s a nonce in module scope that’s required by a remote service, the malicious code can’t read that nonce. (Of course there’s always overwriting <code>fetch</code>, not saying you can’t get there but it’s not as easy.) </p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Check if the current pageview is associated with a Marketo lead (without using the ol’ hidden KV HTML trick) ]]></title>
        <description><![CDATA[ Rendering a hidden form just to check if someone is associated is a waste of resources. Here’s how to check efficiently. ]]></description>
        <link>https://blog.teknkl.com/check-if-the-current-pageview-is-associated-with-a-marketo-lead-without-using-the-ol-hidden-kv-html-trick/</link>
        <guid isPermaLink="false">68807f2b3fd9b700018e8fb9</guid>
        <category><![CDATA[ Munchkin ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Wed, 06 Aug 2025 18:15:00 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/07/marketo_quirks.png" medium="image"/>
        <content:encoded><![CDATA[ <p>Knowing if the current pageview is <em>associated</em> with a known lead vs. <em>anonymous </em>is key to a lot of cool personalization.</p><p>For example, you can build the equivalent of “If Known Visitor, show Custom HTML” with a non-Marketo form. Or show instant download links if someone is associated (since clicks will be logged by Munchkin) vs. a modal form if they’re anonymous.</p><div class="kg-card kg-callout-card kg-callout-card-yellow"><div class="kg-callout-emoji">💡</div><div class="kg-callout-text">Notice I said “pageview” rather than “session” or “cookie” to be precise. A page will render in the context of a known record if it has the <code spellcheck="false" style="white-space: pre-wrap;">mkt_tok</code> param from an email click, even if cookies are completely disabled. Of course, that type of association won’t travel to the <i><em class="italic" style="white-space: pre-wrap;">next </em></i>page, but the <i><em class="italic" style="white-space: pre-wrap;">current </em></i>page is associated.</div></div><h2 id="the-old-way">The old way</h2><p>The typical way to do this is create a dedicated “association checker” Marketo form with Known Visitor HTML enabled.</p><ul><li>in the KV HTML block, have a <code>&lt;script&gt;</code> that runs your “if associated” code</li><li>in the regular form, add a Rich Text with a <code>&lt;script&gt;</code> that runs your “if anonymous” code</li></ul><p>When you load this form into a hidden <code>&lt;form style="display:none;"&gt;</code>, either the KV HTML JS <em>or </em>the form JS will run, so it serves as an implicit detection mechanism.</p><p>But you’ve wasted resources loading the Forms 2.0 library, the XDFrame, and the form itself, just to use the built-in known lead lookup. Maybe it just takes an extra second, but it feels sloppy.<em> </em></p><h2 id="the-new-way">The new way</h2><p>What if instead you hit <code>getKnownLead</code> directly? Enter a little function that does just that, <code>checkMktoAssociation()</code>. </p><h2 id="get-the-code">Get the code</h2><p>Grab <code>checkMktoAssociation()</code> from here and rehost it in Design Studio:</p><div class="download-list"><a download="" href="https://assets.codepen.io/250687/teknkl-checkmktoassociation-v1.0.0.js?ref=blog.teknkl.com">teknkl-checkmktoassociation-v1.0.0.js</a></div><h2 id="usage">Usage</h2><p><code>checkMktoAssocation(formHostname,munchkinId,useCache)</code></p><h3 id="parameters">Parameters</h3><ul><li><code>formHostname</code> (string, required)<br>Marketo LP domain or domain alias (do not include the protocol).</li><li><code>munchkinId</code> (string, required)<br>You know this one. 😏</li><li><code>useCache</code> (boolean, optional, default <code>true</code>)<br>Use session storage to remember the last <code>_mkto_trk</code> that was associated. Since cookies can’t go from associated back to anonymous, it’s harmless and ups performance. </li></ul><h3 id="return-value">Return value</h3><p>Promise that either</p><ul><li>resolves with <code>true</code>/<code>false</code> (associated/anonymous) </li><li>rejects with an Error if there’s some network-level or config-level problem (like entering a nonexistent <code>munchkinId</code>)</li></ul><h3 id="examples">Examples</h3><p>Just run, handle errors elsewhere:</p><pre><code class="language-javascript">    checkMktoAssociation("lp.example.com", "123-ABC-456")
    .then( isAssociated =&gt; {    
        if(isAssociated){
          // true, do associated stuff
        } else {
          // false, do anonymous stuff
        } 
    });</code></pre><p>Catch errors and map them to <code>undefined</code>: </p><pre><code class="language-javascript">    checkMktoAssociation("lp.example.com", "123-ABC-456")
    .catch( error =&gt; {
        console.error(error);
        return undefined;
    })
    .then( isAssociated =&gt; {    
        if(isAssociated){
          // true, do associated stuff
        } else {
          // false or undefined, do anonymous stuff
        } 
    });</code></pre><p>If you’re into <code>async</code>/<code>await</code> (meh):</p><pre><code class="language-javascript">    let isAssociated;
    try {
       isAssociated = await checkMktoAssociation("lp.example.com","123-ABC-456");
    } catch(error) {
       console.error(error);
       isAssociated = undefined;
    }</code></pre> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Once you rewrite SMTP headers, you either need to re-DKIM-sign the message or stop checking DKIM ]]></title>
        <description><![CDATA[ Once again forcing you to know what I know about DKIM. ]]></description>
        <link>https://blog.teknkl.com/dkim-signature-invalidated-by-subject-warning/</link>
        <guid isPermaLink="false">68924f4e345b0f000148a8b6</guid>
        <category><![CDATA[ DKIM ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Tue, 05 Aug 2025 17:38:49 -0400</pubDate>
        <media:content url="" medium="image"/>
        <content:encoded><![CDATA[ <p>A client found a single recipient domain where every Marketo email went to Spam due to failing DKIM validation. (A bad DKIM signature should really result in the message being dropped completely, so sending it to Spam was dangerously generous!)</p><p>We knew the DKIM DNS record (<code>m1._domainkey.example.com</code>) for Marketo was correct, and of course were reassured by only one company reporting problems. But still, you have to track things down.</p><h2 id="what-dkim-signs">What DKIM signs</h2><p>DKIM signs the important parts of outbound SMTP messages: <mark>the SMTP body and s set of common SMTP headers</mark>.</p><p>If a DKIM signature is valid, the recipient knows those parts of the message were generated on a server controlled by the signer, which increases trust. </p><p>(It also works both ways: the signer can’t claim they <em>didn’t</em> generate the message. This is known as “<a href="https://en.wikipedia.org/wiki/Non-repudiation?ref=blog.teknkl.com" rel="noreferrer">non-repudiation</a>” and is important in regulated industries.)</p><p>A sample <code>DKIM-Signature</code> header looks like this:</p><pre><code class="language-markup ">DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=relaxed/relaxed; t=1754422263;
	s=m1; d=example.com; i=@example.com;
	h=List-Unsubscribe:List-Unsubscribe-Post:Content-Type:MIME-Version:Subject:To:From:Date;
	bh=+Sv3HC8ADc5Pw461LNuM4JotacUIplmGEq0oW2dh958=;
	b=djmGGvM8Pyh+Wb8pjFRDu1laQpirD7eKY+3QamZTBoRpt0ugu3XlqV82rjVnNxG1
	eVE98WsxcKp7mrS85tt1/AhWjyidytICcu9ijb5NGTIEyeXO92heaIczEN0c1wI8AeU
	839pMpPiCW6LOvJCTTkeLRNIkSEP3RnV/G1+Jblw=</code></pre><p>The <code>h=</code> parameter lists the message <u>h</u>eaders covered by the signature: <code>Subject</code>, <code>To</code>, <code>From</code>, <code>Date</code> and a few others. These <mark>headers cannot be changed without invalidating the signature</mark>. Makes sense, right? If a hacker could change the <code>Subject</code> and <code>From</code> and still have a valid signature, that would undermine protection against impersonation + repudiation. (The message <em>body </em>is always signed and can’t be altered without invalidating the sig, but the body isn’t the only important part.)</p><p>Any header <em>not </em>in that list can be safely changed in transit: for example, the standard <code>Message-ID</code> and nonstandard <code>X-</code> headers like <code>X-MSFBL</code>.<a href="#notes" rel="noreferrer">[1]</a> Setting a new <code>Message-ID</code> can cause other confusion<a href="#notes" rel="noreferrer">[2]</a> of course, but it won’t make the message bad from a DKIM perspective.</p><h2 id="sometimes-the-clues-are-in-all-caps">Sometimes the clues are in [ALL CAPS]</h2><p>It turned out the company had a bad combo of configs. </p><p>They had a mail scanner prepending<code>[MARKETING]</code> to the <code>Subject</code> of Marketo emails after accepting them — something you’ve probably seen before. But when the <code>Subject</code> line is altered, that by definition breaks the DKIM signature if it includes (and it almost always does) the <code>Subject</code>.</p><p>What a server <em>should </em>do, in this order, is:</p><ol><li>validate any DKIM signature(s) against the original content</li><li>assuming signatures were valid, add another header like <code>X-DKIM-Originally-Valid: example.com, mktomail.com</code> </li><li>remove all<code>DKIM-Signature</code> headers; alternately, leave the original <code>DKIM-Signature</code> headers behind, but only if <mark>the message isn’t processed by any other servers that validate DKIM</mark></li><li>change the <code>Subject</code> line or anything else (sometimes those servers add a <code>WARNING</code> to the body too)</li><li>optionally, <em>re-sign </em>the message using the new body + header content; in this case you’ll be signing it on behalf of <em>your </em>domain, not the original sender domain, but the sig can be used by other internal systems  </li></ol><p>What this company was doing instead was changing the <code>Subject</code> line, leaving the <code>DKIM-Signature</code> headers as-is, then forwarding to an SMTP server elsewhere in their infrastructure. That SMTP server was validating DKIM as if the messages were from the outside world. Naturally, all the altered messages failed DKIM!</p><p>The solution was to bypass DKIM validation on that next-hop server, since that check isn’t appropriate if you <em>ever </em>alter content after delivery.</p><h5 id="notes">Notes</h5>
<p>[1]  In this example, copied from a real Marketo email, an intermediate server could also tweak <code>Reply-To</code>. Make of that what you will!</p><p>[2] <mark>The <code>Message-ID</code> header is key to accurate threading in email clients.</mark> When you reply to a message, the original <code>Message-ID</code> is automatically copied to the <code>In-Reply-To</code> of your reply. So if new <code>Message-ID</code>s get assigned they won’t be grouped into the same conversation.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ If someone recommends “Request Token Encoding: None” in a webhook, always push back ]]></title>
        <description><![CDATA[ Using “None” for POST payloads usually means something else has gone terribly wrong. ]]></description>
        <link>https://blog.teknkl.com/if-someone-recommends-request-tokencoding-none-in-a-webhook-theyre-almost-certainly-wrong/</link>
        <guid isPermaLink="false">6883d302f1d18c0001ab0f02</guid>
        <category><![CDATA[ clay ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Sun, 27 Jul 2025 00:52:40 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/07/json_url_etc.png" medium="image"/>
        <content:encoded><![CDATA[ <p>A client recently asked about connecting Marketo to Clay and we were dismayed at Clay’s official docs:</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/07/image-1-1.png" class="kg-image" alt="Step 5: Name and configure your Webhook.  To properly configure Marketo’s webhook to send data to Clay, please fill in the following details exactly as shown:  URL: The webhook URL you copied from Step 1 Payload Template: first_name={{lead.First Name:default=editme}} &amp;last_name={{lead.Last Name:default=editme}} &amp;company={{company.Company Name}} &amp;title={{lead.Job Title}} &amp;email={{lead.Email Address:default=editme}} &amp;id={{lead.Id}} Request Token Encoding: None Request Type: Post Response Format: JSON" loading="lazy" width="1261" height="589" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/07/image-1-1.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/07/image-1-1.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/07/image-1-1.png 1261w" sizes="(min-width: 720px) 720px"><figcaption><span style="white-space: pre-wrap;">Snippet from Clay docs</span></figcaption></figure><p>See how they recommend <em>Request Token Encoding: None?</em> That’s totally wrong, since:</p><ol><li>There are <code>{{lead.tokens}}</code> in the body, i.e. request tokens.</li><li>The body uses URL encoding, indicated by the <code>&amp;</code> and <code>=</code> delimiters.</li><li><mark>You must URL-encode tokens in a URL-encoded body.</mark></li></ol><p>This webhook setup instantly breaks with a <em>Company </em>like <code>Jack &amp; Jill Construction</code>. It’s kind of inconceivable that they tested it at all. </p><p>Moreover, when you actually set up a webhook receiver in the Clay app, it says the payload is supposed to be <em>JSON-encoded, </em>not <em>URL-encoded! </em>So their doc feels like a hallucination. You should actually be using a payload like this:</p><pre><code class="language-javascript">{
  "first_name" : {{lead.First Name}},
  "last_name" : {{lead.Last Name}}
}</code></pre><p>And of course set <em>Request Token Encoding: JSON. </em></p><h2 id="why-would-you-ever-use-request-token-encoding-none">Why would you ever use <em>Request Token Encoding: None?</em></h2><p>In 2 rare cases, <em>None</em> is correct:</p><ul><li>When <em>all </em>fields in your payload — can’t just be a subset — are already JSON- or URL-encoded. In practice, this usually means the payload is a single field written via API, designed for sending as-is.<br><br>For example, you might store a complete JSON object with properties and child objects in a Textarea, and the data is intended to have <em>that same shape </em>on the remote server. If you let Marketo encode <code>{{lead.Big JSON Object}}</code> again, that won’t work: it’ll be one giant string.</li><li>Even more rare is when the remote service expects <em>text/plain</em> payloads. Usually these are old internal services to which you send values like <code>1;NOT PROVIDED;TRUE</code> (that's the entire payload!).<br><br>You don't want to encode the 3 tokens that comprise that payload: it either becomes <code>1;NOT+PROVIDED;TRUE</code> (URL) or <code>1;"NOT PROVIDED";"TRUE"</code> (JSON) which aren't the same.</li></ul><p>In both of these cases, something non-standard is happening and <mark>you should push back on the requester to explain the reason</mark>. </p><h2 id="ps-tokens-in-the-url-are-always-url-encoded-even-with-none-but-pretend-you-don%E2%80%99t-know-that">P.S. Tokens in the <em>URL </em>are always URL-encoded, even with <em>None </em>(but pretend you don’t know that)</h2><p>An undocumented quirk is when the URL, not the request body, contains tokens:</p><pre><code class="language-markup">https://hooks.example.com/123?first_name={{lead.First Name}}&amp;last_name={{lead.Last Name]}</code></pre><p>Those tokens are <em>always </em>URL-encoded, regardless of the <em>Request Token Encoding </em>setting. This is fine! But it’s confusing because the URL is undoubtedly part of the request: <mark><em>Request Token Encoding </em>should be read as <em>Request <strong>Body </strong>Token Encoding</em><em>.</em></mark> </p><p>So be careful not to rest on whether a webhook “works” using GET. When the service switches to POST in a few months and you move those tokens to the body, it’ll break unless you have <em>Request Token Encoding: Form/URL.</em></p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Help! My Velocity {{my.token}} previews correctly, but I can’t approve the email ]]></title>
        <description><![CDATA[ At the moment of approval, fields on the $𝚕𝚎𝚊𝚍 object are empty strings (meaning &quot;&quot;, not 𝑛𝑢𝑙𝑙). Your code needs to deal with that gracefully. ]]></description>
        <link>https://blog.teknkl.com/help-my-velocity-my-token-previews-correctly-but-i-cant-approve-the-email/</link>
        <guid isPermaLink="false">686de4f8db0b2d0001ca5d04</guid>
        <category><![CDATA[ smtp ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Wed, 09 Jul 2025 02:03:08 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/07/velocity-marketo-logo.png" medium="image"/>
        <content:encoded><![CDATA[ <p>Velocity is already verbose, but you’ve still gotta <a href="https://labex.io/tutorials/java-how-to-address-java-lang-arrayindexoutofboundsexception-417316?ref=blog.teknkl.com" rel="noreferrer">code defensively</a> even if that means a little more code.</p><p>Crucially, in the Marketo-Velocity ecosystem, <mark>don’t assume code will only run in the context of a Person record.</mark> Prepare for person fields and object lists to be empty. This is true, for example, <mark>during asset approval</mark>.</p><p>Take this little snippet that adds a footer including the email domain:</p><pre><code class="language-velocity">#set( $emailParts = $lead.Email.split("@") )
#set( $emailDomain = $emailParts[1] )
If this email was mistakenly routed to your spam folder, ask the administrator of the ${emailDomain} mailserver to allow future emails from info@example.com. </code></pre><p>It’ll run fine when you preview by a Person or List, but when you try to approve you’ll get: </p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/07/image.png" class="kg-image" alt="" loading="lazy" width="915" height="387" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/07/image.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/07/image.png 915w" sizes="(min-width: 720px) 720px"></figure><p>You can’t see the full error in the popup, but if you look at the underlying Java error it’s an <code>ArrayIndexOutOfBoundsException</code>.<a href="#notes" rel="noreferrer">[1]</a></p><p>And this makes sense! On line 1 the (overly naïve) email parser does a <code>split()</code> on <code>@</code>, then on line 2 it <mark>assumes the resulting array has at least 2 items</mark>.</p><p>This assumption is true of any <em>valid </em>email address. But when you approve, the placeholder value in <code>$lead.Email</code>, as with all Person fields checked off in Script Editor, is an empty string. You can <code>split()</code> an empty string, no problem. But the array only has 1 item (also an empty string). So if you try to seek the 2nd item — index <code>[1]</code> since arrays are 0-based — that’s out of bounds.</p><p>While Velocity swallows a lot of errors that would be fatal in pure Java, this isn’t one of them. Exceeding the length of an array is always fatal.</p><h2 id="coding-for-approve-email">Coding for <em>Approve Email</em></h2><p>Simplest defensive move is checking if <code>$lead.Email</code> is empty before continuing. This will let you approve the asset:</p><pre><code class="language-velocity">#if( !$lead.Email.isEmpty() )
#set( $emailParts = $lead.Email.split("@") )
If this email was mistakenly routed to your spam folder, ask the administrator of the ${emailDomain} mailserver to allow future emails from info@example.com.
#end</code></pre><p>The above assumes <code>$lead.email</code> is either <em>empty </em>or <em>valid. </em>A valid address must have an <code>@</code> sign, so it doesn’t check beyond that.</p><p>If you want to also cover people with an <em>invalid </em>— but non-empty – <code>$lead.Email</code>, switch it around so you <code>split()</code> and check the expected length: </p><pre><code class="language-velocity">#set( $emailParts = $lead.Email.split("@") )
#if( $emailParts.size() &gt; 1 )
#set( $emailDomain = $emailParts[1] )
If this email was mistakenly routed to your spam folder, ask the administrator of the ${emailDomain} mailserver to allow future emails from info@example.com.
#end</code></pre><p>Even better, rethink <em>split-and-get-2nd-item</em>. As noted above, this is a naïve way of parsing the domain out of an email address.<a href="#notes" rel="noreferrer">[2]</a> Don’t you really mean <em>split-and-get-last-item? </em>I think you do. So if you do that, <mark>no matter whether <code>$lead.email</code> is empty, valid, or invalid</mark>, there’s no error:</p><pre><code class="language-velocity">#set( $emailParts = $lead.Email.split("@") )
#set( $emailDomain = $emailParts[$math.sub($emailParts.size(),1)] )
If this email was mistakenly routed to your spam folder, ask the administrator of the ${emailDomain} mailserver to allow future emails from info@example.com. </code></pre><h5 id="notes">Notes</h5>
<p>[1] In a local Velocity environment you see the stack trace:</p><pre><code class="language-markup">Error invoking method 'get(java.lang.Integer)' in [Ljava.lang.String; at dynmacro.vm[line 52, column 33]
        at org.apache.velocity.runtime.parser.node.ASTIndex.execute(ASTIndex.java:175)
        at org.apache.velocity.runtime.parser.node.ASTReference.execute(ASTReference.java:280)
        at org.apache.velocity.runtime.parser.node.ASTReference.value(ASTReference.java:567)
        at org.apache.velocity.runtime.parser.node.ASTExpression.value(ASTExpression.java:71)
        at org.apache.velocity.runtime.parser.node.ASTSetDirective.render(ASTSetDirective.java:142)
        at org.apache.velocity.runtime.parser.node.SimpleNode.render(SimpleNode.java:342)
        at org.apache.velocity.Template.merge(Template.java:356)
        at org.apache.velocity.Template.merge(Template.java:260)
        at VTLRunner.main(VTLRunner.java:92)
Caused by: java.lang.reflect.InvocationTargetException
        at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
        at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:39)
        at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:25)
        at java.lang.reflect.Method.invoke(Method.java:597)
        at org.apache.velocity.util.introspection.UberspectImpl$VelMethodImpl.doInvoke(UberspectImpl.java:395)
        at org.apache.velocity.util.introspection.UberspectImpl$VelMethodImpl.invoke(UberspectImpl.java:384)
        at org.apache.velocity.runtime.parser.node.ASTIndex.execute(ASTIndex.java:149)
        ... 8 more
Caused by: java.lang.ArrayIndexOutOfBoundsException
        at java.lang.reflect.Array.get(Native Method)
        at org.apache.velocity.util.ArrayListWrapper.get(ArrayListWrapper.java:43)</code></pre><p>[2] It’s wrong for old-school but valid syntax like <code>"sandy@home"@example.com</code>.  </p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Careful with native NodeJS fetch() in serverless setups (with any API, not just the Marketo API) ]]></title>
        <description><![CDATA[ Reminder (if you needed one) that newer doesn’t mean more predictable. ]]></description>
        <link>https://blog.teknkl.com/take-care-with-the-fetch-api-in-serverless-environments-whether-youre-calling-the-mar/</link>
        <guid isPermaLink="false">68276e0073dc4000018f0de0</guid>
        <category><![CDATA[ Node ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Sun, 06 Jul 2025 23:28:24 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/05/awsstuff.png" medium="image"/>
        <content:encoded><![CDATA[ <p>For years, we’ve leaned heavily and happily on AWS Lambda to stretch Marketo into other realms.</p><p>A simple Lambda lets you integrate with, say, event platforms, transforming their generic <em>outbound </em>webhooks to valid <em>inbound </em>Marketo API calls. Especially cool are “loopback integrations”: a Marketo webhook calls a Lambda that swoops back in to call the Marketo API, allowing juiced-up flows of all sorts.</p><p><a href="https://en.wikipedia.org/wiki/Serverless_computing?ref=blog.teknkl.com" rel="noreferrer">Serverless</a> architectures pioneered by AWS are perfect when a service <mark>doesn’t need to run hot 24/7 but must still be <em>available and scalable </em>24/7</mark>, i.e. when a second of spin-up time after idling or increased load isn’t a problem.<a href="#notes" rel="noreferrer">[1]</a></p><p>But the dynamic spin-down/spin-up comes with undocumented risks. One rears its head with newer (but not older!) versions of NodeJS Lambdas. To make matters worse, <mark>you won’t see the problem when developing your Lambda offline, only when it’s really running within AWS</mark>.<a href="#notes" rel="noreferrer">[2]</a></p><h2 id="a-simple-example">A simple example</h2><p>A snippet from a Lambda that receives an inbound payload, transforms it into a proper <em>Push Lead </em>payload, gets an OAuth token, and sends on to Marketo:</p><pre><code class="language-javascript">let outboundJSON = transformCventToMarketo(inboundJSON);
fetch( marketoIdentityURL )
.then( extractAccessToken )
.then( accessToken =&gt; 
  fetch( 
    marketoPushLeadURL, 
    { 
      method: "POST",
      headers: { 
        ...accessToken, 
        ...contentTypeJSON 
      },
      body: outboundJSON 
    })
    .then( successOrRetry )
)
.catch( failure )</code></pre><p>To be clear, there’s nothing wrong with this logic. (It implies the access token isn’t cached, but that’s tricky to do in Lambda anyway.<a href="#notes" rel="noreferrer">[3]</a>)</p><p>Yet when you deploy to AWS and start putting the code through its paces, you’ll sporadically see a fatal error:</p><pre><code class="language-javascript">"TypeError: fetch failed"</code></pre><p>If you switch back to a version of NodeJS that <mark>doesn’t have native <code>fetch</code> and instead use a polyfill</mark> like <code>node-fetch</code>, you won’t get the errors!</p><h2 id="what%E2%80%99s-the-deal">What’s the deal?</h2><p>Weirdly, nobody really knows.</p><p>We know NodeJS’s native <code>fetch</code> is a compiled-in version of the renowned <a href="https://www.npmjs.com/package/undici?ref=blog.teknkl.com" rel="noreferrer">Undici module</a>, maintained by some of the greatest HTTP developers in the world. Undici has been part of NodeJS since v18 and is continually upgraded.</p><p>And we know there <em>aren’t </em>reports of this problem with regular NodeJS (i.e. on your own server/container), <em>only </em>with NodeJS on Lambda. A very diligent user <a href="https://github.com/nodejs/undici/issues/3133?ref=blog.teknkl.com" rel="noreferrer">wrote up his findings</a> in April 2024, but so far there isn’t any closure. And we can attest that it still happens with the latest NodeJS v22 on Lambda.</p><p>The direct cause seems to be Undici’s keep-alive connection pool being clobbered by an external “resource governor” — whatever proprietary controls AWS has in place to ensure Lambdas don’t exceed memory or execution time limits. The rest is a black box.<a href="#notes" rel="noreferrer">[4]</a> </p><h2 id="the-workaround">The workaround</h2><p>Using <code>node-fetch</code> will work, but seems like a step backward. We’ve instead told Undici not to keep connections alive, like so:</p><pre><code class="language-javascript">let outboundJSON = transformCventToMarketo(inboundJSON);
fetch( marketoIdentityURL, { 
  headers: { 
    Connection: "close" 
  } 
})
.then( extractAccessToken )
.then( accessToken =&gt; 
  fetch( 
    marketoPushLeadURL, 
    { 
      method: "POST",
      headers: { 
        Connection: "close", 
        ...accessToken, 
        ...contentTypeJSON 
      },
      body: outboundJSON 
    })
    .then( successOrRetry )
)
.catch( failure )</code></pre><p>This solved the problem for our high-traffic Lambdas.</p><h5 id="notes">Notes</h5>
<p>[1] In the event platform example, if there aren’t any registrations for awhile, the running Lambda environments spin down to save resources. They automatically spin back up when a registrant comes in, but that first request might take an extra second. The delay would never be felt by an end user, since it’s a backend-to-backend connection.</p><p>[2] Kinda like when you write working Velocity code offline, then paste it into a token and go <strong><em>Wha??? </em></strong>The deployed version always holds some surprises.</p><p>[3] Yes, you can cache data in the top level scope, but as more containers spin up each one ends up with its own cache. </p><p>[4] We’re not onboard with developer <a href="https://github.com/nodejs/undici/issues/3133?ref=blog.teknkl.com#issuecomment-2061264011" rel="noreferrer">mcollina’s suggestion</a> that it’s due to the entire NodeJS process being suspended/spun down; the error happens with a Lambda instance that stays running across invocations. But we’re definitely convinced some Lambda special sauce is responsible, not NodeJS.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ How an “unintrusive” 3rd-party tracking script caused duplicate Marketo form posts ]]></title>
        <description><![CDATA[ Gotta love devs who don’t know Marketo but say “Just drop it in, trust us.” ]]></description>
        <link>https://blog.teknkl.com/how-an-unintrusive-3rd-party-tracking-script-caused-duplicate-marketo-form-posts/</link>
        <guid isPermaLink="false">67d52c1ed0d9f40001d818a3</guid>
        <category><![CDATA[ forms js ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Sun, 06 Jul 2025 03:06:00 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/03/justbad.png" medium="image"/>
        <content:encoded><![CDATA[ <p>The sheer <em>audacity </em>of 3rd-party form trackers (conversion pixels, enrichment services, attribution libraries) is amazing. <strong><em>“Don’t worry about telling IT,” </em></strong>they’ll say. <strong><em>“It has zero side effects, just drop it onto every landing page.” </em></strong></p><p>If their code is correctly using the Marketo Forms JS API, that’d be reassuring. But in reality, most use a Marketo-ignorant — and for that matter, Hubspot-ignorant and everything-else-ignorant, too — approach: they <mark>look for <code>&lt;form&gt;</code> elements in the page and at least <em>try, </em>to varying degrees, to listen for native DOM events</mark> (focus, change, submit) without disrupting the final outcome.</p><p>This approach can easily fail. Take a certain social media conversion tracker that was <mark>causing every Marketo form to submit twice with the same data</mark>:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/03/image-3.png" class="kg-image" alt="" loading="lazy" width="1298" height="483" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/03/image-3.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/03/image-3.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/03/image-3.png 1298w" sizes="(min-width: 720px) 720px"></figure><p>This naturally messes up all kinds of Smart Campaigns, and even creates duplicates<a href="#notes" rel="noreferrer">[1]</a> for net new leads.</p><p>How did they screw up? Here’s a trimmed-down equivalent of their code: </p><pre><code class="language-javascript">htmlForm.addEventListener("submit", function(e) {
   e.stopImmediatePropagation();
   e.preventDefault();

   const htmlFormButton = htmlForm.querySelector("button[type='submit']");
   htmlFormButton.disabled = true;

   socialTrackFormValues()
   .then( () =&gt; {
      htmlFormButton.disabled = false;
      htmlFormButton.click();
   });
}, { once: true });</code></pre><p>What they think it’s doing: </p><ol><li>intercepting the end user’s click on the submit button</li><li>pausing the form from submitting for the moment</li><li>relaying form values to their service</li><li>resuming the submission by synthetically clicking the button (i.e. backwards-compatible <a href="https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/requestSubmit?ref=blog.teknkl.com" rel="noreferrer"><code>requestSubmit()</code></a>), this time without intercepting it</li></ol><p>What it’s actually doing:</p><ol><li>listening for the end user’s click on the submit button, <em>without </em>pausing the submission</li><li>submitting the form a 2nd time by synthetically clicking the button</li></ol><h2 id="why-the-discrepancy">Why the discrepancy?</h2><p>The direct reason this wasn’t working is <mark>the social tracking code was injected via a tag manager</mark>. Because of fundamental <code>&lt;script&gt;</code> ordering rules, it runs after the Marketo form is initially rendered, and after Marketo attaches its own native and custom event listeners. If you run <code>stopImmediatePropagation()</code>, that stops <em>later </em>event listeners from seeing the event. You can’t stop <em>earlier </em>event listeners, they’re already done!</p><p>Also, <code>preventDefault()</code> is irrelevant for a Marketo form. Marketo forms are built with standard <code>&lt;form&gt;</code> elements, a Very Good Thing both semantically and <a href="https://en.wikipedia.org/wiki/Principle_of_least_astonishment?ref=blog.teknkl.com" rel="noreferrer">POLA</a>-tically. But the data is sent using XMLHttpRequest (Ajax), not a primitive GET/POST to the <code>action</code> URL. Marketo is already <code>prevent</code>ing <code>Default</code>. </p><p>So their code does <em>some of </em>what it promises to do: it does relay the Marketo form data to the external service, and nothing is duplicated <em>there, </em>at least.<em> </em>But it wreaks havoc on the Marketo side.</p><h2 id="is-there-a-better-marketo-forms-aware-way">Is there a better, Marketo Forms-aware way?</h2><p>That depends.</p><p>If you’re just cross-posting form data to an outside service and don’t care about the response, you can hook Forms 2.0 <code>onSubmit</code><a href="#notes" rel="noreferrer">[2]</a> and <code>sendBeacon</code><a href="#notes" rel="noreferrer">[3]</a> the data. No need to worry about pausing the form submit because the beacon will finish in the background.</p><p>But if you’re using the response from an outside service to change/add fields on the form, there’s no <strong><em>“It just works”</em></strong> solution for all Marketo forms. If you’re writing such a tracker, you must (a) be transparent about what the code attempts automatically and (b) offer a way to disable anything automatic and publish a JS API, so IT can merge your expectations with other form behaviors.   </p><h3 id="notes">Notes</h3><p>[1] When 2 form posts for the same <em>brand new </em>email address arrive in the same second, Marketo can’t see Lead 1 already exists before creating Lead 2. This won’t happen if the email address is already in your instance. </p><p>[2] Though do you know you’re the final <code>onSubmit</code> listener and thus can trust the form data is complete and <code>submittable</code>? See, it’s always tricky!</p><p>[3] Or Fetch with <code>keepalive</code>.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Have you no sense of DNS latency, sir, at long last? ]]></title>
        <description><![CDATA[ Troubleshooting slow-loading email images reveals a classic culprit. ]]></description>
        <link>https://blog.teknkl.com/have-you-no-sense-of-dns-latency-sir-at-long-last/</link>
        <guid isPermaLink="false">67e99fafb086580001d2230f</guid>
        <category><![CDATA[ DNS ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Sat, 28 Jun 2025 15:29:13 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/03/dns.png" medium="image"/>
        <content:encoded><![CDATA[ <p>As primitive as SMTP and DNS may seem, lots of stuff needs to work for an email to go from sender ⮕ recipient’s MX ⮕ recipient’s mailbox. Then <em>even more stuff </em>needs to work for a mail client to parse and render an HTML email, plus its embedded resources and/or remote images. </p><p>The good news: modern infrastructure is reliable. We don’t worry much about extended network outages or servers melting down. But that’s also misleading,  since <mark>fault-tolerant infrastructure, plus the native resilience of SMTP and DNS, can disguise poor performance</mark>. </p><p>Put another way, <strong>stuff stays up, but that doesn’t mean it stays <em>fast.</em></strong></p><h2 id="dns-is-everywhere">DNS is everywhere</h2><p>I recently looked into a problem with email image load performance for a client. Check out the animation below. The left side shows the expected load performance with an unprimed local cache.[1] This was happening most of the time. But sometimes they’d see performance like the right side: a noticeably staggered experience, more than twice as slow.</p><img src="https://blog-images-teknkl-com.s3.amazonaws.com/blog_dnslatency_51333bf7-c667-4e1c-8bed-b03f54e19e0e_sbs.apng"><p>Quite puzzling. There was no correlation to particular mail clients or domains. Over a huge number of manual tests, we saw the behavior many times, but the Network tab didn’t show anything interesting besides “yep, it’s slow.”<a href="#notes" rel="noreferrer">[2]</a></p><p>Clearly, we weren’t getting anywhere manually. So I set up a monitoring job to download one of those images every 100ms.</p><p>Next morning I checked the logs and found a critical clue. Response times varied widely, and <mark>around 1% of the hits were returning an HTTP 502 Bad Gateway response.</mark> That meant their CDN (image cache) couldn’t connect to the origin server (the primary source of their images, a DAM system).</p><p>Now that didn’t make much sense, because we were already monitoring the origin server and to our knowledge it never went down, with response time steadily around 30ms.</p><p>But something clicked when I looked at the nameservers for the origin domain, let’s call it <code>dam-images.example.com</code>:</p><pre><code class="language-markup">example.com.          86400   IN      NS      ans11.dnshosting.example.
example.com.          86400   IN      NS      ans5.dnshosting.example.
example.com.          86400   IN      NS      ans6.dnshosting.example.
example.com.          86400   IN      NS      ans8.dnshosting.example.</code></pre><p>Now bear in mind the NS records are returned in random order. That’s just one example, and a future query could return:</p><pre><code class="language-markup">example.com.          86400   IN      NS      ans8.dnshosting.example.
example.com.          86400   IN      NS      ans6.dnshosting.example.
example.com.          86400   IN      NS      ans5.dnshosting.example.
example.com.          86400   IN      NS      ans11.dnshosting.example.</code></pre><p>So pay less attention to the order and more to the fact there are 4 different nameservers.</p><p>I looked up <code>dam-images.example.com</code> on each nameserver. A <code>dig</code> command like</p><pre><code class="language-markup">dig dam-images.example.com @ans11.dnshosting.example</code></pre><p>gets results only from the server <code>ans11.dnshosting.example</code>. (If that server is up, that is; otherwise, <code>dig</code> times out after 15 seconds.)</p><p>3 of the 4 servers responded immediately with the same set of A records:</p><pre><code class="language-markup">dam-images.example.com. 60 IN     A       13.35.10.1
dam-images.example.com. 60 IN     A       13.35.10.4
dam-images.example.com. 60 IN     A       13.35.10.7
dam-images.example.com. 60 IN     A       13.35.10.8</code></pre><p>But one server, <code>ans5.dnshosting.example</code>, always timed out. Further investigation revealed <code>ans5</code> was retired permanently during a network cutover. That is, <mark>it was <em>known </em>to be down, which is typically harmless because DNS is resilient.</mark></p><h2 id="dns-resilience-is-a-net-good-but-has-some-edges">DNS resilience is a net good, but has some edges </h2><p>DNS resolvers automatically try <mark>every nameserver before giving up.</mark> This keeps your domain’s DNS working even if only 1 NS is available. (Most corporate domains publish 4 NS records, some as many as 8.<a href="#notes" rel="noreferrer">[3]</a>)</p><p>NSes aren’t tried strictly in (random) sequence nor in parallel, but a combination. Every resolver uses different logic, for example:</p><ul><li>query NS 1 and wait 1s for a response</li><li>query NS 2 and wait 1s</li><li>query NS 2 again and wait 2s</li><li>query NS 3 and wait 4s</li><li>query all NSes in parallel and wait 8s</li><li>etc.</li></ul><p>The net effect of this retry logic is you almost always get a response. But the elapsed time varies. If your nameservers never go down and always instantly respond, great! But if some are unreliable due to spotty networks or sloppy setups, while everything “still works” in the grand scheme, there will be a range of user-facing experiences.</p><h2 id="here%E2%80%99s-what-happened">Here’s what happened</h2><p>What we found was the CDN (call it <code>global-images.example.com</code>) enforced a 5-second timeout in connecting to the origin (<code>dam-images.example.com</code>). That timeout covered the DNS lookup time + time-to-first-byte (TTFB) of the HTTP response.</p><p>The vast majority of the time, the DNS (cached or uncached) + HTTP combo would start in under 1s, but ~10% of of the time it would creep closer to 5s. And 1% of the time it would time out completely — which we only saw when we set up additional monitoring.</p><p>The origin server was perfectly healthy; the performance hit was entirely due to <mark>DNS latency when the “wrong” NS came up first in the shuffle</mark>! Also surprising was that the CDN was querying all NSes throughout the day, rather than pinning a high-performance one.<a href="#notes" rel="noreferrer">[4]</a></p><p>The solution was to remove the known-down NS completely from DNS. Which is too bad, really, because it <em>should’ve </em>had no effect. But you never know what shenanigans intermediate servers — particularly CDNs’ proprietary network stacks — will get up to. </p><h3 id="notes">Notes</h3><p>[1] i.e. the image URLs had not previously been fetched by the client.</p><p>[2] Browsers implement a quite lengthy timeout for images: it’s 3 minutes in Chrome, for example. These delays were more in the few-seconds range, so they weren’t anywhere close to <em>that </em>timeout.</p><p>[3] There’s no hard limit on the the number of nameservers for a domain: if you commit to <a href="https://www.netmeister.org/blog/dns-size.html?ref=blog.teknkl.com" rel="noreferrer">DNS over TCP</a> and very short hostnames, you could stuff thousands of NS RRs into a response. But there’s no practical utility beyond 20 or so: “You still got a response after 10 minutes, that doesn’t count as downtime” isn’t gonna be convincing!</p><p>[4] Most resolvers remember which NS was fastest and then <mark>try that one first next time around</mark>. But this “pinning” isn’t permanent and will be reset after a fixed period and upon reboot. Most examples in this post assume a resolver has no cached info about a domain.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Yes, you can use client-side {{ variable }} syntax (AngularJS, LiquidJS, Mustache, etc.) on Marketo LPs ]]></title>
        <description><![CDATA[ Marketo uses both ${ } and {{ }} syntax internally, which makes mixing in other template languages tricky. But there’s always a way. ]]></description>
        <link>https://blog.teknkl.com/yes-you-can-use-client-side-variable-syntax-angularjs-liquidjs-mustache-etc-on-marketo-lps/</link>
        <guid isPermaLink="false">685e1de95c8f3d00016e3b9e</guid>
        <category><![CDATA[ angularjs ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Fri, 27 Jun 2025 14:10:47 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/07/lp_editor.png" medium="image"/>
        <content:encoded><![CDATA[ <p>Extending my <a href="https://blog.teknkl.com/reserved-marketo-characters-like-dollar-on-landing-pages-my-tokens/" rel="noreferrer">earlier post</a> on standard JavaScript <code>${template strings}</code> on Marketo LPs, here’s how to use near-standard <code>{{ variable }}</code> syntax.</p><p>Say you want to use AngularJS to create highly dynamic LPs, or even single-page apps, hosted on Marketo. (May not be a common ask, but it is <em>technically </em>feasible!)</p><p>You might declare a little <code>ng-app</code> like this:</p><pre><code class="language-markup">&lt;div ng-app=""&gt;
&lt;p&gt;First Name: &lt;input type="text" ng-model="firstName" /&gt;&lt;/p&gt;
&lt;p&gt;Last Name: &lt;input type="text" ng-model="lastName" /&gt;&lt;/p&gt;
&lt;h1&gt;Hello {{ firstName }} {{ lastName }}!&lt;/h1&gt;
&lt;/div&gt;</code></pre><p>Problem is, you won’t be able to approve the LP: </p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/06/image-4.png" class="kg-image" alt="" loading="lazy" width="759" height="534" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/06/image-4.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/06/image-4.png 759w" sizes="(min-width: 720px) 720px"></figure><p>The popup doesn’t provide details, but the root cause is <mark>the super common <code>{{ }}</code> delimiters, which Marketo reserves for internal use</mark>: that’s how we deploy <code>{{lead.tokens}}</code>, <code>{{my.tokens}}</code>, and so on! Yet you aren’t using a valid <code>dot.ted</code> variable name inside them, so it’s a syntax error.</p><p>Seeing the error is good actually, since if you <em>were </em>using a <code>dot.ted</code> name, Marketo would either (a) output nothing if the token didn’t exist internally or (b) output the internal token value if it happens to exist. Both would be confusing!</p><p>One solution is to change the delimiters (what Angular calls the <a href="https://docs.angularjs.org/api/ng/provider/$interpolateProvider?ref=blog.teknkl.com" rel="noreferrer">interpolate provider</a>) to something non-conflicting like <code>[! !]</code>. The drawback is the final template looks different from all your other templates, and it’s also not as easy to search+replace because of possible delimiter collisions.<a href="#notes" rel="noreferrer">[1]</a></p><p>A more elegant solution is to skip Marketo’s internal token substitution process. Do that by creating a <code>{{my.token}}</code> for the opening curly braces (you don’t need one for the closing braces):</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/06/image-3.png" class="kg-image" alt="" loading="lazy" width="903" height="219" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/06/image-3.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/06/image-3.png 903w" sizes="(min-width: 720px) 720px"></figure><p>Search+replace the literal <code>{{</code> with <code>{{my.open-curlies}}</code> everywhere:</p><pre><code class="language-markup">&lt;div ng-app=""&gt;
&lt;p&gt;First Name: &lt;input type="text" ng-model="firstName" /&gt;&lt;/p&gt;
&lt;p&gt;Last Name: &lt;input type="text" ng-model="lastName" /&gt;&lt;/p&gt;
&lt;h1&gt;Hello {{my.open-curlies}} firstName }} {{my.open-curlies}} lastName }}!&lt;/h1&gt;
&lt;/div&gt;</code></pre><p>Now Marketo won’t take ownership of the variable, so the <code>{{</code> passes through to the browser where Angular takes over:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/06/image-5.png" class="kg-image" alt="" loading="lazy" width="582" height="291"></figure><h5 id="notes">Notes</h5>
<p>[1] e.g. if a template already contained the literal string <code>[It’s time for fall!]</code> it’ll be erroneously included in a switch back to <code>}}</code> in the future, unless you tune your search+replace regex ruthlessly.  </p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Without checking SFDC Is Deleted, you don’t know if someone’s currently synced to SFDC ]]></title>
        <description><![CDATA[ You know some system fields in Marketo are more historical than current (looking at you, Email Suspended). But did you know SFDC Type and SFDC ID also fall into that bucket? ]]></description>
        <link>https://blog.teknkl.com/without-checking-sfdc-is-deleted-you-dont-know-if-someones-synced-to-sfdc/</link>
        <guid isPermaLink="false">6854b332ff839e0001e327b1</guid>
        <category><![CDATA[ salesforce ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Mon, 23 Jun 2025 11:00:00 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/06/sfdcsync.png" medium="image"/>
        <content:encoded><![CDATA[ <p>I’d bet at least 51% of Marketo users don’t realize a non-empty <em>SFDC Type </em>doesn’t mean the record is still synced to SFDC. Same with a non-empty <em>SFDC ID.</em></p><p>That’s right: <mark>someone can have <em>SFDC Type = <code>Contact</code></em> and <em>SFDC ID = <code>003BLAHZAYBLAHBLAH</code></em> and there may be no trace of them in SFDC</mark>. </p><p>Why? Because those fields reflect <em>the last time they were synced </em>and will not be updated unless they’re synced with another SFDC record in the future. And that last connected SFDC record may have been completely deleted.</p><p>Don’t believe it? Create a Smart List like this:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/06/image-2.png" class="kg-image" alt="" loading="lazy" width="1422" height="479" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/06/image-2.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/06/image-2.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/06/image-2.png 1422w" sizes="(min-width: 720px) 720px"></figure><p>You might see thousands of records. (If you regularly delete people who have <em>SFDC Is Deleted = <code>true</code></em>, the number won’t be as high.<a href="#notes" rel="noreferrer">[1]</a>)</p><p>This came up recently on Marketo Nation, when someone wanted to add the person’s current SFDC Type to certain alerts (e.g. <code>type=L</code> or <code>type=C</code>). That could be done via a Formula field:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/06/image-1.png" class="kg-image" alt="" loading="lazy" width="1212" height="1104" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/06/image-1.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/06/image-1.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/06/image-1.png 1212w" sizes="(min-width: 720px) 720px"></figure><p>N.B. Formula fields can’t be used in filters, only in {{lead.tokens}} — and only in trigger campaigns, at that!<a href="#notes" rel="noreferrer">[2]</a> But you can adapt the same logic to <mark>create a segmentation + segments, update a roll-up field nightly, or create a nestable Smart List.</mark></p><h2 id="sfdc-is-deleted-still-leaves-room-for-questions"><em>SFDC Is Deleted </em>still<em> </em>leaves room for questions</h2><p>Don’t forget someone can have <em>SFDC Type = <code>Contact</code></em>/<em><code>Lead</code></em> and <em>SFDC Is Deleted = <code>false</code></em> yet <mark>no longer be syncing successfully to SFDC due to permissions/sharing rules</mark>.</p><p>It’s impossible to know at a glance whether such records were:</p><p>(a) supposed to be synced but are erroneously blocked, or </p><p>(b) supposed to be deleted from Marketo and left SFDC-only</p><p>If Marketo can’t access a previously accessible record, it doesn’t assume it was deleted from SFDC (rightly so!). <em>SFDC Is Deleted</em> means "actually deleted on the SFDC side." So there are always other conditions to consider.</p><h5 id="notes">Notes</h5>
<p>[1] Summarily deleting people whose <em>SFDC Is Deleted = <code>true</code> </em>isn’t something to do recklessly. A lead may have been erroneously synced to SFDC, but should remain Marketo-only, rather than being purged from Marketo.</p><p>[2] The original question was about alerts, so a Formula fits.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ See quoted &quot;{{ variables }}&quot; in a template that’s generating JSON? You’re probably seeing a security hole ]]></title>
        <description><![CDATA[ If you claim you’re sending JSON but the letters 𝚓-𝚜-𝚘-𝚗 don’t appear in your code, you’re in danger. ]]></description>
        <link>https://blog.teknkl.com/see-quoted-variables-in-a-template-thats-generating-json-youre-probably-seeing-a-security-hole/</link>
        <guid isPermaLink="false">6838b8e3a2e44f0001852cd2</guid>
        <category><![CDATA[ security ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Sun, 22 Jun 2025 14:18:27 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/06/justbad.png" medium="image"/>
        <content:encoded><![CDATA[ <p>There may be no code smell ranker than this:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/06/codeimage-snippet_19--2-.png" class="kg-image" alt="Snippet: {   &quot;comments&quot; : &quot;{{ form.comments }}&quot; }" loading="lazy" width="960" height="286" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/06/codeimage-snippet_19--2-.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/06/codeimage-snippet_19--2-.png 960w" sizes="(min-width: 720px) 720px"></figure><p>That screenshot was sent by a 3rd-party form vendor who was getting frequent errors from their Marketo API calls and couldn’t figure out what was wrong.</p><p><strong><em>“Does this look right?” </em></strong>they asked. <strong><em>“Nope,” </em></strong>I said immediately — even before they showed me any error messages.</p><p>See, even taking Marketo out of the picture, <mark>this is wrong for <em>any </em>API.</mark> If you see something like this, you’re in the presence of dangerous amateurs.</p><h2 id="no-way-is-that-value-correctly-json-encoded">No way is that value correctly JSON-encoded</h2><p>It’s clear they’re not encoding since there are double quotes <em>outside </em>the template variable. <mark>A JSON-encoded string always includes the double quotes.</mark> (Simple JavaScript example: <code>JSON.stringify("Hello")</code> is <code>"Hello"</code> with the quotes, not <code>Hello</code>.)</p><p>Let’s review how their “bizarro JSON” logic works.</p><p>They’re using <a href="https://jinja.palletsprojects.com/en/stable/templates/?ref=blog.teknkl.com" rel="noreferrer">Jinja</a> templates, but any template language works the same way. Since there’s no filter specified, the property <code>form.comments</code> is output <em>exactly as stored on the back end </em>with no encoding, replacing the token <code>{{ form.comments }}</code>.</p><p>Assume the raw value is:</p><pre><code class="language-javascript">I like your style.</code></pre><p>Since they’re not encoding at all, they end up with this JSON:</p><pre><code class="language-javascript">{
  "comments" : "I like your style."
}</code></pre><p>So far, so (misleadingly) good.</p><p>But assume the raw value is:</p><pre><code class="language-javascript">Please send a sample of your industrial tiles.

I’m in Denmark, hope it won’t take too long.</code></pre><p>You end up with this totally broken payload (remember, <mark>JSON doesn’t allow literal line breaks inside strings</mark>):</p><pre><code class="language-javascript">{
  "comments" : "Please send a sample of your industrial tiles.

I’m in Denmark, hope it won’t take too long."
}</code></pre><p>Now you start to see the problem. Any character that requires encoding breaks the whole thing. But it gets worse...</p><h2 id="way-worse">... way worse.</h2><p>The above examples presume innocence. But <mark>user-supplied input is never safe to output as-is</mark>. Doesn’t matter how much validation you think you’re doing: databases will let you store arbitrary data in a string field, and crazy stuff <em>will </em>get in there.<a href="#notes" rel="noreferrer">[1]</a></p><p>Imagine an attacker purposely posts this value in the <em>comments </em>field on your custom form:</p><pre><code class="language-javascript ">Hi", "secretKey" : "abc123</code></pre><p>The back end injects that value into the template as-is, so the assembled API payload becomes:</p><pre><code class="language-javascript">{
  "comments" : "Hi", "secretKey" : "abc123"
}</code></pre><p>The attacker just <mark>tricked you into updating the field <code>secretKey</code></mark> with a value of their choosing, while you <em>think </em>you’re just updating <code>comments</code>. That’s a crazy <a href="https://www.crowdstrike.com/en-us/cybersecurity-101/cyberattacks/injection-attack/?ref=blog.teknkl.com" rel="noreferrer">injection attack</a><em> </em>vulnerability! It’s not as familiar as SQL injection or command injection, but should be just as horrifying.<a href="#notes" rel="noreferrer">[2]</a></p><h2 id="and-that%E2%80%99s-why-you-always-json-encode">And THAT’s why you always JSON-encode</h2><p>Any template language worth using has a JSON filter or will let you add a custom one. In Jinja, it’s <code>tojson</code>. So the correct template is:</p><pre><code class="language-javascript">{
  "comments" : {{ lead.comments | tojson }}
}</code></pre><p>Now all payloads above are correctly escaped and neutralized:</p><pre><code class="language-javascript">{
  "comments" : "I like your style."
}</code></pre><pre><code class="language-javascript">{
  "comments" : "Please send a sample of your industrial tiles.\n\nI’m in Denmark, hope it won’t take too long."
}</code></pre><pre><code class="language-javascript">{
  "comments" : "Hi\", \"secretKey\" : \"abc123"
}</code></pre><p>All of those only update the <code>comments</code> field, as intended.</p><p><mark>If you’re not <em>explicitly </em>using JSON encoding — something that has the 4 letters <code>j</code>-<code>s</code>-<code>o</code>-<code>n</code> in it — it’s 99% likely your code has the same vulnerability.</mark></p><h5 id="notes">Notes</h5>
<p>[1] There may be special-purpose databases out there that do permanent validation within the storage engine (meaning not just <code>BEFORE INSERT/UPDATE</code> triggers, not just <code>CHECK</code> constraints, but some compiled-in feature that can’t be turned off). Never seen such a thing myself, and I’ll bet your company isn’t using one! </p><p>[2] Such vulnerabilities don’t just let attackers <em>overwrite </em>values, they may allow <em>reading </em>existing values too. What if the API lets you include a list of fields to return, which you <em>think </em>you’ve hard-coded in the template:</p><pre><code class="language-javascript">{
  "getFields": [
    "firstName",
    "lastName"
  ],
  "setFields": {
    "comments" : "{{ form.comments }}"
  }
}</code></pre><p>Send this carefully crafted hack as <em>Comments:</em></p><pre><code class="language-javascript ">Hi" }, "getFields": [ "password" ], "closer" : { "a" : "b</code></pre><p>Now the JSON payload is:</p><pre><code class="language-javascript">{
  "getFields": [
    "firstName",
    "lastName"
  ],
  "setFields": {
    "comments" : "Hi" }, "getFields": [ "password" ], "closer" : { "a" : "b" 
  }
}</code></pre><p>Oops! You’ve successfully made the API response include the <code>password</code>.</p><p>And if the back end relays the response to the browser — reasonable if you expect it to only contain “safe” fields — you’ve got a major compromise.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Conditional content that only shows in View As Web Page mode ]]></title>
        <description><![CDATA[ View As Web Page has so many superpowers over plain email, you almost feel like breaking the email HTML on purpose. ]]></description>
        <link>https://blog.teknkl.com/conditional-content-that-only-shows-in-view-as-web-page-mode/</link>
        <guid isPermaLink="false">6824dd49bc91ca0001423fdc</guid>
        <category><![CDATA[ CSS ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Thu, 15 May 2025 01:25:21 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/05/css_cssom.png" medium="image"/>
        <content:encoded><![CDATA[ <p>A community user wondered if they could show an email’s Subject line in <em>View As Web Page </em>mode<em>.</em></p><p>It makes sense that Subject and other SMTP headers aren’t part of the <em>VAWP </em>display: it’s designed to show the HTML <em>content</em>. (And the Subject is typically reflected in the content anyway, albeit with different phrasing.)</p><p>But you can set any element to only show in <em>View As Web Page – </em>including a copy of the Subject line!</p><p>Just hide the content by default in all clients, as you would with an email preheader:</p><pre><code class="language-markup">&lt;table id="emailSubjectMirror" style="mso-hide:all;mso-line-height-rule:exactly;visibility:hidden;opacity:0;color:transparent;line-height:0;font-size:0px;display:none;"&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;They’re tariffin’ pariffin, but our tactical candles are made here in the US of Flamin’ A&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;</code></pre><p>Then include this CSS, which will unhide the content in <em>VAWP</em> mode:</p><pre><code class="language-css">body:has(#forwardtoFriendDropDown) #emailSubjectMirror {
    display: unset !important;
    visibility: unset !important;
    opacity: unset !important;
    color: unset !important;
    line-height: unset !important;
    font-size: unset !important;
}</code></pre><p>(<em>VAWP</em> always injects a <em>Forward to Friend </em>container, even when it's not in use. So that's how you detect it.)</p><p>Obviously you need not <code>unset</code> the color and font completely, that’s just a simplified example of un-hiding the element. You can set whatever styles you want.</p><p>P.S. You can make <em>VAWP </em>very slick via by adding non-email-compatible CSS or even JS. The ROI might not compete with that same time spent jazzing up your LPs, of course, but it’s worth an A/B test. </p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Check if someone’s email is @ their website — i.e. is their work address — using FlowBoost ]]></title>
        <description><![CDATA[ Marketo filters can’t do “field1 [ends with] @field2” but FlowBoost handles that easily. ]]></description>
        <link>https://blog.teknkl.com/check-if-a-leads-email-is-at-their-website-using-flowboost/</link>
        <guid isPermaLink="false">68463c615575f000015bab52</guid>
        <category><![CDATA[ FlowBoost ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Wed, 14 May 2025 19:52:00 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/06/flowboostjs_new.png" medium="image"/>
        <content:encoded><![CDATA[ <p>Requiring a person’s work address on forms may be too frictional, or you simply may not control the lead source. But you can still derive value from the data points you do have. </p><p>An example is comparing someone’s <em>Website </em>value with their <em>Email.</em> If <em>Website </em>— derived from <em>Company </em>via enrichment <em>— </em>is <code>intavro.com</code> and <em>Email </em>is<em> </em><code>sandy@intavro.com</code>, that’s a positive signal.</p><p>FlowBoost has the precompiled function <a href="https://blog.teknkl.com/you-dont-know-how-easy-to-parse-email-domain-using-flowboost/" rel="noreferrer"><code>FBUtil.string.partsFromEmail()</code></a>so you don’t have to code that yourself, and of course offers the standard <a href="https://developer.mozilla.org/en-US/docs/Web/API/URL?ref=blog.teknkl.com" rel="noreferrer">URL API</a> as <code>FBHttp.URL</code>. </p><p>So it’s as as simple as:</p><pre><code class="language-javascript">let emailAddress = {{lead.Email Address}};
let website = {{lead.Website}};

let emailParts = FBUtil.string.partsFromEmail(emailAddress);

// Website field may or may not contain the leading http://
let webURL;
if( FBHttp.URL.canParse(website) ) {
  webURL = FBHttp.URL.parse(website);
} else {
  webURL = FBHttp.URL.parse("https://" + website);
}

itMatches = emailParts.domain == webURL.hostname;</code></pre><p>Then <code>itMatches</code> is a Boolean to use in a response mapping.</p><p>Notice I didn’t <code>toLowerCase()</code> either side. That’s because <code>partsFromEmail().domain</code> is already lowercased, as is <code>new URL().hostname</code>.<a href="#notes" rel="noreferrer">[1]</a></p><h2 id="alternate-approach">Alternate approach</h2><p>I prefer to parse both values into parts/components for flexibility, but technically you can do:</p><pre><code class="language-javascript">let emailAddress = {{lead.Email Address}};

// Website field may or may not contain the leading http://
let webURL;
if( FBHttp.URL.canParse(website) ) {
  webURL = FBHttp.URL.parse(website);
} else {
  webURL = FBHttp.URL.parse("https://" + website);
}

itMatches = emailAddress.toLowerCase().endsWith("@" + webURL.hostname);</code></pre><h5 id="notes">Notes</h5>
<p>[1] Specifically because the protocol is <code>https:</code> and that’s a <a href="https://url.spec.whatwg.org/?ref=blog.teknkl.com#special-scheme" rel="noreferrer">special scheme</a> in the standard. You can use the <code>URL()</code> constructor with any protocol, but non-special ones don’t lowercase <code>hostname</code> because stuff that doesn’t use DNS may be case-sensitive. (URLs are general-purpose and hosts aren’t always DNS-based hosts!)</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Filling hidden fields from utm_* params on Squarespace forms ]]></title>
        <description><![CDATA[ Every time I’m forced to dip into other form builders, I miss Marketo forms. Marketo’s are not only more programmer-friendly, they also do things out of the box that require custom JS elsewhere. ]]></description>
        <link>https://blog.teknkl.com/filling-hidden-fields-from-utm_-params-on-squarespace-forms/</link>
        <guid isPermaLink="false">6801664d409dbe0001860619</guid>
        <category><![CDATA[ squarespace ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Sat, 19 Apr 2025 20:42:37 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/04/misc_form_builders_and_widgets.png" medium="image"/>
        <content:encoded><![CDATA[ <p>Another chance to sing the praises of Marketo forms over other form builders. Marketo forms are just, well, <strong><em>normal</em></strong>. Others’ forms, by and large, are needlessly weird and obscure.</p><p>Take filling fields from UTM params. On a Marketo form, you just set up a field as type <em>Hidden </em>and set it to Autofill from a param like <code>utm_campaign</code>.</p><p>Doesn’t matter what the back end Marketo field is called: could be literally <code>utm_campaign</code>, or the more precise <code>last_utm_campaign</code>, or one of those fields with a backstory like <code>UTM_Campaign_Really_Use_This_One_Guys</code>.</p><p>Not so with a Squarespace form. There, <mark>even if a field is called <code>utm_campaign</code> it’s impossible to fill it from the param <code>utm_campaign</code></mark>. Unless you use the custom JS below, you can only fill from <a href="https://support.squarespace.com/hc/en-us/articles/206543997-Tracking-form-block-referral-sources?ref=blog.teknkl.com" rel="noreferrer">param names that start with <code>SQF_</code></a> and are all caps. That’s crazy! </p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/04/image-2.png" class="kg-image" alt="" loading="lazy" width="459" height="708"></figure><p>Nobody wants to append <code>SQF_UTM_CAMPAIGN</code> to links. Even if <em>teeeechnically </em><code>utm_*</code> params aren’t “standard,” they’re so widely supported that they might as well be.</p><p>Ideally, you’d pop the Marketo form embed code in a Squarespace code block instead of using Squarespace forms, sidestepping this issue. But maybe someone at your company likes the way Squarespace forms look and/or has an exaggerated belief in the Squarespace ⮕ Marketo integration. If that’s the case, then open <em>Page Settings » Advanced:</em></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/04/image-1.png" class="kg-image" alt="" loading="lazy" width="1348" height="1016" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/04/image-1.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/04/image-1.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/04/image-1.png 1348w" sizes="(min-width: 720px) 720px"></figure><p>And paste this code in the <em>Code Injection </em>pane:</p><pre><code class="language-markup">&lt;script&gt;
    const currentURL = new URL(document.location.href);
    for( const [name,value] of currentURL.searchParams ) {        
        if( /^utm_/.test(name) ) {
            const sqfName = `SQF_${name.toUpperCase()}`;
            currentURL.searchParams.set(sqfName, value);
        }
     }
     history.replaceState({}, "", currentURL.href);
&lt;/script&gt;</code></pre><p>That’ll take care of it.</p><p>The code simply <em>copies </em>each standard <code>utm_&lt;name&gt;</code> param to a corresponding <code>SQF_UTM_&lt;name&gt;</code>. (The original <code>utm_&lt;name&gt;</code> param is not removed, so it still works in GA/Clicky/etc.)</p><p>Because Squarespace injects your custom code <em>before </em>its default field-filling code, it’ll fill the fields like we want.</p><h2 id="i-know-what-you%E2%80%99re-thinking-but-don%E2%80%99t-even-bother">I know what you’re thinking, but don’t even bother</h2><p>Yes, my first thought was also <strong><em>“Just wait for the form to render and set the <code>&lt;input type="hidden"</code> values using JS.” </em></strong></p><p>But that won’t work. It would with a vanilla HTML form, but Squarespace insists on <mark><em>re</em>-filling (in addition to <em>pre-</em>filling) the fields</mark> from the querystring when the person clicks submit, so your values will be blanked out. There’s probably a convoluted hack using <code>stopImmediatePropagation()</code>, but it’ll never be easier than the code above.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Is an email address that requires encryption (STARTTLS) a “valid email”? It depends ]]></title>
        <description><![CDATA[ All email validation services fundamentally do the same thing:

 * look up the domain to see if it has an MX record
 * if it has one, connect to the MX and pretend they’re about to send an email
 * quit after sending RCPT TO, before sending a message body
 * report whether ]]></description>
        <link>https://blog.teknkl.com/is-an-email-address-requiring-starttls-invalid-it-depends/</link>
        <guid isPermaLink="false">66f86e49ff36630001ee4a94</guid>
        <category><![CDATA[ validation mistakes ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Tue, 15 Apr 2025 03:01:10 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/04/smtpstuff.png" medium="image"/>
        <content:encoded><![CDATA[ <p>All email validation services fundamentally do the same thing:</p><ul><li>look up the domain to see if it has an MX record</li><li>if it has one, connect to the MX and <em>pretend </em>they’re about to send an email</li><li>quit after sending <code>RCPT TO</code>, before sending a message body</li><li>report whether <code>RCPT TO</code> returned <code>250 OK</code> or not<a href="#notes" rel="noreferrer">[1]</a>  </li></ul><p>They can — okay, since I’ve built such services myself, <strong><em>we </em></strong>can — dress ’em up with “heuristic analysis” and “suspicious domain databases,” but the foundation is the same.     </p><p>Here’s the log of a service hitting our corporate server to validate <code>sandy+test-ssloptional@figureone.com</code>:</p><pre><code class="language-markup">94.46.244.65    [1DB4] 20:44:55 Connected, local IP=66.165.231.34:25
94.46.244.65    [1DB4] 20:44:55 &gt;&gt;&gt; 220 icewarp.figureone.com ESMTP IceWarp Epos Update 1 build 17; Sun, 13 Apr 2025 20:44:55 -0400
94.46.244.65    [1DB4] 20:44:55 &lt;&lt;&lt; EHLO douglas.email-validator.com
94.46.244.65    [1DB4] 20:44:55 &gt;&gt;&gt; 250-icewarp.figureone.com Hello douglas.email-validator.com [94.46.244.65], pleased to meet you.
                                    250-ENHANCEDSTATUSCODES
                                    250-SIZE
                                    250-EXPN
                                    250-ETRN
                                    250-ATRN
                                    250-CHECKPOINT
                                    250-8BITMIME
                                    250-SMTPUTF8
                                    250-AUTH PLAIN LOGIN DIGEST-MD5 CRAM-MD5
                                    250-STARTTLS
                                    250-VRFY
                                    250 HELP
94.46.244.65    [1DB4] 20:44:55 &lt;&lt;&lt; MAIL FROM:&lt;pvchk@email-validator.com&gt;
94.46.244.65    [1DB4] 20:44:55 &gt;&gt;&gt; 250 2.1.0 &lt;pvchk@email-validator.com&gt;... Sender ok
94.46.244.65    [1DB4] 20:44:55 &lt;&lt;&lt; RCPT TO:&lt;sandy+test-ssloptionalfigureone.com&gt;
94.46.244.65    [1DB4] 20:44:56 &gt;&gt;&gt; 250 2.1.5 &lt;sandy+test-ssloptional@figureone.com&gt;... Recipient ok
94.46.244.65    [1DB4] 20:44:56 &lt;&lt;&lt; QUIT
94.46.244.65    [1DB4] 20:44:56 &gt;&gt;&gt; 221 2.0.0 icewarp.figureone.com closing connection
94.46.244.65    [1DB4] 20:44:56 *** &lt;pvchk@email-validator.com&gt; &lt;sandy+test-ssloptional@figureone.com&gt; 1 0 00:00:00 INCOMPLETE-SESSION 
94.46.244.65    [1DB4] 20:44:56 Disconnected</code></pre><p><code>QUIT</code> without <code>DATA</code> logs an <code>INCOMPLETE-SESSION</code>, but no harm, no foul.</p><h2 id="the-validator-is-speaking-for-itself">The validator is speaking for itself</h2><p>Technically, <mark>the validator is checking whether <em>it </em>could send mail, not whether any <em>other </em>server could send mail</mark>.</p><p>There’s still a ton of value to 3rd-party validation, don’t get me wrong! But inevitably you get false positives: the validator says “valid,” while a real send from Marketo or another MAP fails. (The bounce message might even be “Recipient email does not exist.” That’s because <mark>mailservers are under no obligation to tell presumptive spammers the truth</mark>.)</p><p>False positives are typically because (a) the recipient’s mailserver doesn’t accept marketing emails, period, or (b) your IP or domain was blocked due to prior offenses.</p><p>Far rarer are false <em>negatives</em>, where the validator says “invalid” but if you try sending anyway, the email goes through. But a client recently validated their whole db and noticed some people had <em>Email Delivered </em>that very same day, so they couldn’t be invalid. Right? </p><p>Well, depends on your point of view. Not even sure which side <em>I’m </em>on yet, but felt like pubbing this post and getting people thinking.😛</p><h2 id="it%E2%80%99s-about-starttls-a-form-of-secure-smtp">It’s about STARTTLS, a form of secure SMTP</h2><p>SMTP is, or was, a notoriously insecure protocol. If you can tap the network between 2 SMTP servers, or between an SMTP client and server, you can eavesdrop on every message passed between them.</p><p>That servers reside in secure datacenters, and the pipes between them are run by (<em>umm, generally</em>) good actors, mitigates the risk somewhat. But if you’re on a coffee shop LAN and the shop owner is in the mood, they can read all submissions from a traditional email client using ol’ school SMTP.<a href="#notes" rel="noreferrer">[2]</a></p><p>Enter secure SMTP, which comes in 2 flavors:</p><ul><li><a href="https://en.wikipedia.org/wiki/SMTPS?ref=blog.teknkl.com" rel="noreferrer">SMTPS</a>, also known as <em>implicit </em>TLS, which is always encrypted and as secure as HTTPS</li><li>SMTP with <a href="https://en.wikipedia.org/wiki/Opportunistic_TLS?ref=blog.teknkl.com" rel="noreferrer">STARTTLS</a>, also known as <em>opportunistic </em>TLS, which is now <a href="https://datatracker.ietf.org/doc/html/rfc8314?ref=blog.teknkl.com" rel="noreferrer">actively discouraged</a> because encryption isn’t guaranteed </li></ul><p>STARTTLS offers questionable security, since it’s typically <em>offered</em> but not required: both sides of the SMTP transaction, sender and receiver, need to agree to use it.<a href="#notes" rel="noreferrer">[3]</a> If the receiver says “Hey, I support STARTTLS,” the sender can still say “Nah, I’m good with plaintext.” And the transaction will still continue.</p><p>In contrast, SMTPS won’t allow downgrading to unencrypted traffic.[4] That’s why that RFC linked above is like a cease and desist against STARTTLS.</p><p>Nevertheless, STARTTLS is still offered by almost all receivers. But here’s the thing: <mark>it’s not 100% supported by senders.</mark></p><h2 id="requiring-starttls-isn%E2%80%99t-whole-internet-safe">Requiring STARTTLS isn’t whole-internet-safe </h2><p>Google publishes a <a href="https://transparencyreport.google.com/safer-email/overview?hl=en&ref=blog.teknkl.com" rel="noreferrer">Transparency Report</a> measuring the adoption of STARTTLS across the world, as measured on the Gmail platform. While adoption is highest in the Americas, it’s still hovering at 99%+, not quite reaching 100% for consecutive months. In other areas it’s high-90s: highly successful yet not universal. Here’s a worldwide chart since 2020 showing we’re still not <em>quite </em>there:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/04/image.png" class="kg-image" alt="" loading="lazy" width="1791" height="1011" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/04/image.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/04/image.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1600/2025/04/image.png 1600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/04/image.png 1791w" sizes="(min-width: 720px) 720px"></figure><p>So what happens if one of the 1-10% of senders that don’t support STARTTLS connects to a receiver that <em>requires </em>STARTTLS? (We’re not including hackers in this situation, by the way.)</p><p>Simple: the <mark>connection will end with a permanent failure.</mark> The sender will issue an opening <code>EHLO</code>, then the receiver says “OK, you can use STARTTLS.” If the sender doesn’t use STARTLS immediately after that, the receiver says “To be clear, I meant you <em>must </em>use STARTTLS” and sends back a hard <code>530</code> error.</p><p>And that’s what happened with those leads that came up invalid, even though Marketo could send to them. The validation log looked like this:</p><pre><code class="language-markup">94.46.244.65    [2A9C] 20:52:02 Connected, local IP=66.165.231.34:25
94.46.244.65    [2A9C] 20:52:02 &gt;&gt;&gt; 220 icewarp.figureone.com ESMTP IceWarp Epos Update 1 build 17; Sun, 13 Apr 2025 20:52:02 -0400
94.46.244.65    [2A9C] 20:52:02 &lt;&lt;&lt; EHLO douglas.email-validator.com
94.46.244.65    [2A9C] 20:52:02 &gt;&gt;&gt; 250-icewarp.figureone.com Hello email.phone-validator.com [94.46.244.65], pleased to meet you.
                                    250-ENHANCEDSTATUSCODES
                                    250-SIZE
                                    250-EXPN
                                    250-ETRN
                                    250-ATRN
                                    250-CHECKPOINT
                                    250-8BITMIME
                                    250-SMTPUTF8
                                    250-AUTH PLAIN LOGIN DIGEST-MD5 CRAM-MD5
                                    250-STARTTLS
                                    250-VRFY
                                    250 HELP
94.46.244.65    [2A9C] 20:52:02 &lt;&lt;&lt; MAIL FROM:&lt;pvchk@email-validator.com&gt;
94.46.244.65    [2A9C] 20:52:02 &gt;&gt;&gt; 530 5.7.0 Must issue a STARTTLS command first
94.46.244.65    [2A9C] 20:52:02 &lt;&lt;&lt; QUIT
94.46.244.65    [2A9C] 20:52:03 &gt;&gt;&gt; 221 2.0.0 icewarp.figureone.com closing connection
94.46.244.65    [2A9C] 20:52:03 *** &lt;pvchk@email-validator.com&gt; &lt;sandy+test-sslrequired@figureone.com&gt; 1 0 00:00:00 INCOMPLETE-SESSION 
94.46.244.65    [2A9C] 20:52:03 Disconnected</code></pre><p>So the validation service is in the 10% of senders that can’t use STARTTLS. Since it can’t send, it calls the address invalid. Indeed, for any other sender in the 10%, it <em>is </em>invalid — there’s no practical difference between a <code>530 Missing STARTTLS</code> and a <code>550 User unknown</code>, they’re both permanent rejections.<a href="#notes" rel="noreferrer">[5]</a></p><h2 id="is-the-validator-wrong-or-right">Is the validator wrong or right?</h2><p>Depends. If an email is valid only if <mark>every well-behaved/non-blocklisted mailserver in the world can send to it,</mark> then the validator is right. If an email is valid if the overwhelming majority of mailservers can send to it — even if your mailserver can’t — then it’s wrong.</p><p>This also serves as a reminder: the only way to truly know if your Marketo instance can email someone is by emailing them. Generate enough content and you won’t have uncertainty!</p><h3 id="notes">Notes</h3><p>[1] That’s the extent of what you can check in real time. Validators also have a batch mode which allows them to retry if the initial connection is <a href="https://en.wikipedia.org/wiki/Greylisting_(email)?ref=blog.teknkl.com" rel="noreferrer">greylisted</a>.</p><p>[2] Note a <mark>webmail client, mobile app, or proprietary connection like Outlook ⮕ Exchange is not traditional</mark>. I’m talking pure IMAP/SMTP without SSL.</p><p>[3] And that’s not all: that initial discussion takes place over plaintext! Which means a hacker in the middle can simply remove the receiver’s original offer, so the sender never knows it could’ve used encryption — and in turn the unencrypted data can be read by the hacker.</p><p>[4] SMTPS has its own <a href="https://www.mailhardener.com/kb/dane?ref=blog.teknkl.com" rel="noreferrer">barriers</a> to global adoption that I won’t get into here, but it can always be used in the coffee shop scenario.</p><p>[5] Suppose one difference is the <code>530</code> might change over time if the receiver gets the hint that requiring STARTTLS was a bad idea. But as long as the policy is in place, the email address is useless if you don’t support outbound STARTTLS.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ You’re probably overusing “&amp;amp;” in links ]]></title>
        <description><![CDATA[ If a URL’s query params all start with “utm_”, no need to separate them with “&amp;amp;”. A simple “&amp;” will do. ]]></description>
        <link>https://blog.teknkl.com/youre-probably-overusing-amp-in-links/</link>
        <guid isPermaLink="false">67de66c0d24fd300014d326d</guid>
        <category><![CDATA[ URLs and URIs ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Wed, 26 Mar 2025 23:24:40 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/03/browserandurlstuff.png" medium="image"/>
        <content:encoded><![CDATA[ <p>The <strong>tl;dr: </strong>if a URL’s <mark>query params all start with <code>utm_</code>, you don’t need to separate them with <code>&amp;amp;</code>.</mark> Use a simple <code>&amp;</code>.</p><p>Using <code>&amp;amp;</code> when it’s not required is harmless. But it’s not really best practice, since it makes links harder to build by hand <em>and </em>harder to read.</p><p>On the other hand, if you use params other than <code>utm_*</code>, might as well stick with <code>&amp;amp;</code>. Knowing exactly which names/prefixes require <code>&amp;amp;</code> is tricky, as we’ll see below. And if you use a URL builder that automatically spits out <code>&amp;amp;</code> that’s fine.<a href="#notes" rel="noreferrer">[1]</a></p><h2 id="why-you-don%E2%80%99t-need-it-with-utm-params">Why you don’t need it with <code>utm_</code> params</h2><p><code>&amp;amp;</code> is the HTML-encoded form of <code>&amp;</code>. You need in <em>only </em>one case: <mark>when the characters that follow the <code>&amp;</code> would otherwise be confused for one of HTML’s named or numeric entities</mark>. </p><p>This is not the case with with&nbsp;<code>utm_</code>&nbsp;params because there’s no named entity&nbsp;<code>&amp;u</code>,&nbsp;<code>&amp;ut</code>, or&nbsp;<code>&amp;utm</code>. (Named entities only contain letters, so once you get to <code>_</code> you can stop worrying.)</p><p>HTML has a standard list of <a href="https://html.spec.whatwg.org/multipage/named-characters.html?ref=blog.teknkl.com" rel="noreferrer">2200-odd named entities</a> that’s <a href="https://github.com/whatwg/html/blob/main/FAQ.md?ref=blog.teknkl.com#html-should-add-more-named-character-references" rel="noreferrer">guaranteed to never grow</a>. Follow the link for the full entity list; the important part is here, the last stretch that starts with <code>u</code>:</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/03/image-5.png" class="kg-image" alt="screenshot of WHATWG standard: UpTeeArrow     U+021A5	↥ upuparrows;    U+021C8	⇈ urcorn;        U+0231D	⌝ urcorner;      U+0231D	⌝ urcrop;        U+0230E	⌎ Uring;         U+0016E	Ů uring;         U+0016F	ů urtri;         U+025F9	◹ Uscr;          U+1D4B0	𝒰 uscr;          U+1D4CA	𝓊 utdot;         U+022F0	⋰ Utilde;        U+00168	Ũ utilde;        U+00169	ũ utri;          U+025B5	▵ utrif;         U+025B4	▴ uuarr;         U+021C8	⇈ Uuml;          U+000DC	Ü Uuml           U+000DC	Ü uuml;          U+000FC	ü uuml           U+000FC	ü uwangle;       U+029A7	⦧ vangrt;        U+0299C	⦜" loading="lazy" width="655" height="969" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/03/image-5.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/03/image-5.png 655w"><figcaption><span style="white-space: pre-wrap;">snippet of HTML named entity list</span></figcaption></figure><p>As you can see, none match any part of <code>utm</code>. So there will never be a case where <code>&amp;utm</code> is interpreted as anything other than the 4 characters <code>&amp;</code>, <code>u</code>, <code>t</code>, and <code>m</code>.  That’s why you don’t need <code>&amp;amp;utm</code>.</p><h2 id="when-you-do-need-amp">When you <em>do </em>need <code>&amp;amp;</code></h2><p>When you use <code>&amp;amp;</code> (or, to make things more confusing, just <code>&amp;amp</code> without the semicolon) the HTML parser treats that 4- or 5-character sequence as an encoded name — like a macro — for the single character <code>&amp;</code>.<a href="#notes" rel="noreferrer">[2]</a></p><p>That ability to HTML-encode <code>&amp;</code> — to ensure it’s a literal <code>&amp;</code> — can be critical.</p><p>Let’s say your website uses a query param <code>current_language</code>, like <code>current_language=en</code> or <code>current_language=fr</code>. That’s very different than <code>utm_</code>, <mark>because <code>curren</code> is one of the named entities!</mark></p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/03/image-4.png" class="kg-image" alt="screenshot of WHATWG standard: curlywedge;	U+022CF	⋏ curren;	U+000A4	¤ curren	U+000A4	¤ curvearrowleft;	U+021B6	↶ curvearrowright;	U+021B7	" loading="lazy" width="672" height="305" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/03/image-4.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/03/image-4.png 672w"><figcaption><span style="white-space: pre-wrap;">snippet of HTML named entity list</span></figcaption></figure><p>So when you include a querystring-like sequence anywhere in HTML — not just in an <code>&lt;a href&gt;</code>, by the way, but <em>anywhere — </em>and <code>current_language</code> isn’t the first query param, you must use <code>&amp;amp;current_language</code> instead of <code>&amp;current_language</code>.</p><p>If you use the simple <code>&amp;</code>, <code>&amp;curren</code> will be parsed as the named entity and turned into its underlying character. You’ll send people to bizarre URLs like this:</p><pre><code class="language-markup">https://www.example.com/landingpage.html?product=laptops¤t_language=en</code></pre><p>See what happened there? <code>&amp;curren</code> was replaced by the <a href="https://en.wikipedia.org/wiki/Currency_sign_(generic)?ref=blog.teknkl.com" rel="noreferrer">generic currency sign</a> <code>¤</code>. Now you effectively have a single query param <code>product</code> with the value <code>laptops¤t_language=en</code>. Yikes upon yikes.</p><p>So <code>&amp;amp;</code> absolutely has its place. It’s just not needed with the standard marketer’s collection of <code>utm_</code> params.</p><h3 id="notes">Notes</h3><p>[1] Provided those URLs are used in HTML <em>Text </em>or <em>Attribute </em>data. <mark>You <strong>must not </strong>use HTML-encoding (neither <code>&amp;amp;</code> nor <code>&amp;anythingelse;</code>) in CSS <code>&lt;style&gt;</code> nor JS <code>&lt;script&gt;</code> content, nor in the Location bar,  nor in tag names, nor anywhere outside HTML.</mark></p><p>[2] And that replaced <code>&amp;</code> is disconnected from its following characters, even if they’re in the named entity list. Named entity evaluation is not recursive. That would be a nightmare!</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Want to hide a personalized LP section when a {{lead.token}} is empty? Just use CSS :has(:empty) ]]></title>
        <description><![CDATA[ Modern CSS pseudo-classes can do stuff that used to require JS. (Though you should still learn JS!) ]]></description>
        <link>https://blog.teknkl.com/want-to-hide-a-whole-section-when-a-lead-token-is-empty-just-use-css-has-empty/</link>
        <guid isPermaLink="false">67deffbeaa18bf0001d21655</guid>
        <category><![CDATA[ CSS ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Wed, 26 Mar 2025 23:20:00 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/03/css_cssom.png" medium="image"/>
        <content:encoded><![CDATA[ <p>Say you have an LP section like this:</p><pre><code class="language-markup">&lt;div id="intro"&gt;
Good to see you back, {{lead.First Name}}. You can still benefit from intro pricing if you sign up before June 1st.
&lt;/div&gt; </code></pre><p>The LP is public, so some sessions will be anonymous and <code>{{lead.First Name}}</code> is empty. Heck, even if the session is associated with a known lead, if you don’t know their <em>First Name </em>you don’t want to fall back to “friendo.”</p><p>So <mark>the whole section must be hidden if <code>{{lead.First Name}}</code> comes up empty</mark>.</p><p>You can do this using just CSS, no JS required. First, put a <code>&lt;span&gt;</code> around the token:</p><pre><code class="language-markup">&lt;div id="intro"&gt;
Good to see you back, &lt;span id="name-token"&gt;{{lead.First Name}}&lt;/span&gt;. You can still benefit from intro pricing if you sign up before June 1st.
&lt;/div&gt; </code></pre><p>Then use this CSS:</p><pre><code class="language-css">#intro:has(#name-token:empty) {
  display: none;
}</code></pre><p>CSS <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/:empty?ref=blog.teknkl.com" rel="noreferrer"><code>:empty</code></a> selects elements that are completely empty (a.k.a. have no Text nodes) — like the <code>&lt;span&gt;</code> when <code>{{lead.First Name}}</code> is empty. And <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/:has?ref=blog.teknkl.com" rel="noreferrer"><code>:has</code></a> selects elements that <em>contain </em>elements matching another selector.</p><p>So the combo <code>:has(:empty)</code> means “element that contains an empty element.” Then you can style the parent element to your heart’s content.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Let Todd @ Sponge show how to sync fields across Marketo duplicates using FlowBoost ]]></title>
        <description><![CDATA[ Records with the same email address should have (for the most part) the same preferences. Perfect case for the ’Boost. ]]></description>
        <link>https://blog.teknkl.com/let-todd-sponge-show-how-to-synchronize-field-values-across-marketo-duplicates-using-flowboost-2/</link>
        <guid isPermaLink="false">67cf48f67bd093000115cbd9</guid>
        <category><![CDATA[ friends at sponge ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Thu, 20 Mar 2025 22:35:40 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/03/flowboostjs_new.png" medium="image"/>
        <content:encoded><![CDATA[ <p>Once again, my friend Todd Sprinkel from <a href="https://sponge.io/?ref=blog.teknkl.com" rel="noreferrer">Sponge.io</a> has taken on the FlowBoost documentation I’m too busy/lazy to get to. Here he shows a FlowBoost ’hook to keep custom fields synced across duplicates: </p><figure class="kg-card kg-bookmark-card"><a class="kg-bookmark-container" href="https://sponge.io/how-to-solving-the-duplicate-problem-in-marketo-with-flowboost/?ref=blog.teknkl.com"><div class="kg-bookmark-content"><div class="kg-bookmark-title">How to: Solving the Duplicate Problem in Marketo with FlowBoost - Sponge.io | Marketing and Revenue Ops</div><div class="kg-bookmark-description">Keep custom email preferences in sync across duplicate records in Marketo using FlowBoost, ensuring accurate communication preferences.</div><div class="kg-bookmark-metadata"><img class="kg-bookmark-icon" src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/icon/cropped-logo_blue_clear-270x270.png" alt=""><span class="kg-bookmark-author">Sponge.io | Marketing and Revenue Ops</span><span class="kg-bookmark-publisher">Todd Sprinkel</span></div></div><div class="kg-bookmark-thumbnail"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/thumbnail/giphy.gif" alt="" onerror="this.style.display = 'none'"></div></a></figure> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Determining if a Data Value Change is a “real” change, in a few different languages ]]></title>
        <description><![CDATA[ We all love a Unicode base character collator, don’t we? ]]></description>
        <link>https://blog.teknkl.com/determining-if-a-data-value-change-is-a-real-change-in-a-few-different-languages/</link>
        <guid isPermaLink="false">67cdeb917f7e880001522503</guid>
        <category><![CDATA[ unicode ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Thu, 20 Mar 2025 22:20:00 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/03/unicodestuff.png" medium="image"/>
        <content:encoded><![CDATA[ <p>As <a href="https://blog.teknkl.com/checking-if-a-data-value-change-was-real-or-just-a-change-of-case-2/" rel="noreferrer">explored recently</a>, Marketo uses exact string comparison (case-sensitive and accent-sensitive) to determine if fields have changed:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/03/image-1-1-.png" class="kg-image" alt="" loading="lazy" width="2000" height="243" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/03/image-1-1-.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/03/image-1-1-.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1600/2025/03/image-1-1-.png 1600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w2400/2025/03/image-1-1-.png 2400w" sizes="(min-width: 720px) 720px"></figure><p>And this is is, overall, A Good Thing. Case changes to first and last names, or accent adjustments like “Rege-Jean” to “Regé-Jean,” are significant[1]<em> </em>and obviously must be saved to the database.</p><p>It’s only <mark><em>unnecessary bulk changes </em>of this sort that are a problem</mark> because they increase sync latency: one of our clients had an enrichment service that was upper-casing thousands of values every day, only for SFDC to switch back to the original values due to field permissions. What a waste!</p><h2 id="surveying-your-%E2%80%9Creal%E2%80%9D-vs-%E2%80%9Cless-real%E2%80%9D-changes-using-the-marketo-rest-api">Surveying your “real” vs. “less real” changes using the Marketo REST API</h2><p>The <a href="https://developer.adobe.com/marketo-apis/api/mapi/?ref=blog.teknkl.com#operation/getLeadActivitiesUsingGET" rel="noreferrer"><em>Get Lead Activities</em></a><em> </em>and <a href="https://developer.adobe.com/marketo-apis/api/mapi/?ref=blog.teknkl.com#operation/getLeadActivitiesUsingGET" rel="noreferrer"><em>Get Lead Changes</em></a><em> </em>endpoints provide the <code>newValue</code> and <code>oldValue</code> for any change. Extracting changes and using a <em>Unicode base character collator </em>will tell you how many “less real” changes are happening in your instance. </p><p>Decided to flex my cross-language skills, such as they are, and show how to create &amp; use that collator in JavaScript, Java, PHP, and Python. Each example sets the Boolean <code>isEquiv</code> to <code>true</code> if the values only differ by case or accent.</p><p>You can see the code is very similar. Interestingly, JS (my favorite language) uses <code>sensitivity</code>, while the others call it <a href="https://unicode-org.github.io/icu/userguide/collation/concepts.html?ref=blog.teknkl.com#:~:text=The%20ICU%20Collation%20Service%20supports%20many%20levels%20of%20comparison%20(named%20%E2%80%9CLevels%E2%80%9D,%20but%20also%20known%20as%20%E2%80%9CStrengths%E2%80%9D)" rel="noreferrer"><code>strength</code></a>; <em>base sensitivity </em>and <em>primary strength </em>mean the same thing.</p><h3 id="javascript">JavaScript</h3><pre><code class="language-javascript">let baseLetterComparator = Intl.Collator(undefined, { sensitivity: "base" });
let isEquiv = baseLetterComparator.compare(newValue, oldValue) == 0;</code></pre><h3 id="java">Java</h3><pre><code class="language-java">import java.text.*;

public class Main {
  public static void main(String[] args) {
    Collator baseLetterComparator = Collator.getInstance();
    baseLetterComparator.setStrength(Collator.PRIMARY);
    boolean $isEquiv = baseLetterComparator.compare(newValue, oldValue) == 0;
  }

}</code></pre><h3 id="php">PHP</h3><pre><code class="language-php">$baseLetterComparator = new Collator("");
$baseLetterComparator-&gt;setStrength(Collator::PRIMARY);
$isEquiv = $baseLetterComparator-&gt;compare(newValue, oldValue) == 0;</code></pre><h3 id="python">Python</h3><p>(My least favorite language, but I know some of you like it. Note Python is the only one that requires a 3rd-party library, PyICU, to do such a simple thing. Not exactly beating the allegations.😜)</p><pre><code class="language-python">from PyICU import Collator

baseLetterComparator = Collator.createInstance()
baseLetterComparator.setStrength(Collator.PRIMARY)
isEquiv = baseLetterComparator.compare(newValue, oldValue) == 0</code></pre><h3 id="">  </h3><h3 id="notes">Notes</h3><p>[1] I’m strongly against apps that claim to “fix” proper nouns, since <mark>“de la Vega” and “De La Vega” are not the same name</mark>.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ I’ll be at Adobe Summit 2025 ]]></title>
        <description><![CDATA[ NBD but I will be prowling around if you want to say hello. ]]></description>
        <link>https://blog.teknkl.com/ill-be-at-adobe-summit-2025/</link>
        <guid isPermaLink="false">67c893e4a1fe660001fc4962</guid>
        <category><![CDATA[ summit ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Wed, 05 Mar 2025 13:27:01 -0500</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/03/liveevents.png" medium="image"/>
        <content:encoded><![CDATA[ <p>Thanks to Adobe’s generosity, I’ll be in Vegas this year from Tuesday-Thursday, March 18-20. (Though I suck at mornings, so effectively just Tue-Wed.)</p><p>At some point, I’ll be helping out at the Adobe Advocate booth, not sure of the exact time slot yet. Other than that, catch me mostly on the trade floor among my people.</p><p>If you want to link up, use my <a href="https://blog.teknkl.com/contact-me/" rel="noreferrer">contact page</a>, MOPs Pros Slack, or Marketo Nation PM (or email or text if you’ve got ’em).</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ SFDC Campaign Members can still sync to Marketo, even if the related Contact/Lead no longer syncs ]]></title>
        <description><![CDATA[ This wouldn’t surprise a senior SFDC admin, but it’s not widely understood on the Marketo side. ]]></description>
        <link>https://blog.teknkl.com/sfdc-campaign-members-sync-to-marketo-even-if-the-linked-contact-lead-no-longer-does/</link>
        <guid isPermaLink="false">67c2b3d6f330d30001dc7647</guid>
        <category><![CDATA[ salesforce ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Wed, 05 Mar 2025 13:00:00 -0500</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/03/marketo_quirks.png" medium="image"/>
        <content:encoded><![CDATA[ <p>Much of MOps is explaining why things that seemingly <em>shouldn’t </em>happen (a) clearly are and (b) with good reason.</p><p>Case in point: it’s possible for a Contact’s <em>fields </em>to stop updating from Salesforce ➞ Marketo and vice versa, while that Contact’s new <em>campaign memberships </em>continue to sync to Marketo!</p><p>Why? Because SFDC Sharing Rules, as shown here...  </p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/03/image.png" class="kg-image" alt="" loading="lazy" width="1595" height="323" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/03/image.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/03/image.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/03/image.png 1595w" sizes="(min-width: 720px) 720px"></figure><p>… don’t pertain to the Campaign Member object, which as you probably know is the junction object/cross-reference between Contacts and Campaigns.</p><p>This can lead to undue panic, where you might worry Marketo doesn’t have access to SFDC fields, or <em>Last Modified Date </em>is out of whack, or other wrong turns.</p><p>If you right-click and force <em>Sync to SFDC </em>in these cases, you’ll see an error in the Activity Log about being unable to reach a previously visible sObject, while <em>Added to SFDC Campaign </em>comes through fine<em>. </em>Needless to say, <mark>if someone’s being added to campaigns you expect to use in Marketo, they should be made visible to Marketo’s SFDC User!</mark></p><p>In one recent case, a bunch of people had the wrong Record Type. Their sales owner switched them to <em>Private Sales </em>at some point — not taking into account that they were already in Marketo, of course! — and forgot to switch them back to general <em>Sales.</em> </p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ 2 flavors of autofill for multivalued fields: Original (semicolons) and Extra Spicy (standards-based) ]]></title>
        <description><![CDATA[ Easy to fill a field — either visible or hidden — from query params. For multivalued fields like Checkboxes, you’ve got 2 ways to format the params. Both are easy, but only one is correct.☺ ]]></description>
        <link>https://blog.teknkl.com/filling-multivalued-fields-from-query-params-two-different-ways/</link>
        <guid isPermaLink="false">67c16182f330d30001dc7615</guid>
        <category><![CDATA[ JS ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Wed, 05 Mar 2025 01:31:37 -0500</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/02/formsjs-3.png" medium="image"/>
        <content:encoded><![CDATA[ <p>Very reasonable ask: <strong><em>“Can we ‘prime’ a form with selections based on the query string?”</em></strong></p><p>That is, if the query string includes “GC5000 series” and “GC3000 series,” check those boxes automatically, letting the user change those selections if desired:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/03/image-1.png" class="kg-image" alt="" loading="lazy" width="749" height="333" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/03/image-1.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/03/image-1.png 749w" sizes="(min-width: 720px) 720px"></figure><p>We sure can, with a few lines of JS! Only question is <mark>what does it mean for a query string to “include” multiple field values</mark>?</p><h2 id="the-standards-based-way-multiple-params">The standards-based way: multiple params</h2><p>The standard<a href="#notes" rel="noreferrer">[1]</a> — and strongly recommended — way is to include <mark>the same query param multiple times,</mark> clearly indicating a <em>multivalued param:</em></p><pre><code>productInterest=GC5000+series&amp;productInterest=GC3000+series</code></pre><p>Frustratingly, lots of people don’t know about that, probably because their URL parser is poorly written and doesn’t understand it. But since <a href="https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams?ref=blog.teknkl.com" rel="noreferrer">URLSearchParams</a> is available everywhere now, that’s no excuse:</p><pre><code class="language-javascript ">const currentURL = new URL(document.location.href);
const initialProductInterest = currentURL.searchParams.getAll("productInterest");
// initialProductInterest is an array with zero or more values</code></pre><p>And Forms 2.0 already supports that array:</p><pre><code class="language-javascript">MktoForms2.whenReady(function(readyForm){
  readyForm.setValues({
    productInterest: initialProductInterest
  });
});</code></pre><h2 id="the-non-standard-way-semicolon-delimited">The non-standard way: semicolon-delimited</h2><p>When people don’t know the standard way, they decide on a <em>sub-delimiter </em>character(s) to separate values, like:</p><pre><code>productInterest=GC5000+series;GC3000+series</code></pre><pre><code>productInterest=GC5000+series,GC3000+series</code></pre><pre><code>productInterest=GC5000+series|GC3000+series</code></pre><p>Even seen this unholy format:</p><pre><code>productInterest=*!GC5000+series!*+*!GC3000+series!*</code></pre><p>All that just to avoid using the standard!</p><p><mark>The Forms 2.0 API supports the first of these, the semicolon.</mark> Therefore these 2 snippets have the same effect:</p><pre><code class="language-javascript">MktoForms2.whenReady(function(readyForm){
  readyForm.setValues({
    productInterest: ["GC5000 series","GC3000 series"]
  });
});</code></pre><pre><code class="language-javascript">MktoForms2.whenReady(function(readyForm){
  readyForm.setValues({
    productInterest: "GC5000 series;GC3000 series”
  });
});</code></pre><p>Remember, an arbitrary sub-delimiter means <mark>that character can’t appear <em>within </em>a value</mark>. Marketo sees <code>;</code> as special, and since there’s no way to escape it, it can’t be made non-special. If a field is multivalued<a href="#notes" rel="noreferrer">[2]</a>, <code>tl;dr</code> represents the values <code>tl</code> and <code>dr</code>.</p><p>If a query param is <em>known in advance </em>to be semicolon-flavored, simply get and set the value:</p><pre><code class="language-javascript">const currentURL = new URL(document.location.href);
const initialProductInterest = currentURL.searchParams.get("productInterest");

MktoForms2.whenReady(function(readyForm){
   readyForm.setValues({
      productInterest: initialProductInterest
   });
});</code></pre><h2 id="both-flavors-at-once">Both flavors at once</h2><p>I was interested in supporting <em>both </em>methods simultaneously. Say your existing URLs use semicolons, but all new URLs will use multiple params, and you don’t want anything to break.</p><p>Turns out to be pretty easy: </p><pre><code class="language-javascript">const currentURL = new URL(document.location.href);
const initialProductInterest = currentURL.searchParams.getAll("productInterest");
const initialProductInterestCoerced = initialProductInterest.flatMap( itm =&gt; itm.split(/\s*;\s*/) );

MktoForms2.whenReady(function(readyForm){
   readyForm.setValues({
      productInterest: initialProductInterestCoerced
   });
});</code></pre><ul><li>first, <code>getAll()</code> parameter values </li><li>within that array of values, there might be semicolon-delimited sub-values, which we <code>split()</code> out, creating another (temporary) dimension</li><li>finally, <mark>flatten the array so it’s single-dimensional</mark> and ready to be passed to <code>setValues()</code></li></ul><p>So if <code>getAll()</code> returns <code>["apples;oranges"]</code> we transform it to <code>["apples", "oranges"]</code>. If <code>getAll()</code> returns <code>["apples", "oranges"]</code> we simply keep it that way; an empty array <code>[]</code> also stays empty.</p><p><a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/flatMap?ref=blog.teknkl.com" rel="noreferrer">flatMap</a> is a cool method to have in your back pocket!</p><h2 id="be-aware-of-whenready-vs-whenrendered">Be aware of <code>whenReady</code> vs <code>whenRendered</code></h2><p>The examples use <code>whenReady</code> on the assumption the field is always shown. If the field is subject to Visibility Rules, use <code>whenRendered</code> instead, plus <em>dirty checking </em>logic to avoid overwriting the person’s custom selections. More on that in a future post.</p><h3 id="notes">Notes</h3><p>[1] That’s how HTML forms work when multiple inputs have the same <code>name</code>. For example, if the 2 bottom checkboxes are checked:</p><pre><code class="language-markup ">&lt;form method="POST"&gt;
   &lt;label&gt;&lt;input type="checkbox" name="pw_cc__ProductInterest__c" value="GC1000 series"&gt;GC1000 series&lt;/label&gt;
   &lt;label&gt;&lt;input type="checkbox" name="pw_cc__ProductInterest__c" value="GC5000 series"&gt;GC5000 series&lt;/label&gt;
   &lt;label&gt;&lt;input type="checkbox" name="pw_cc__ProductInterest__c" value="GC3000 series"&gt;GC3000 series&lt;/label&gt;
   &lt;input type="submit" value="Submit"&gt;
&lt;/form&gt;</code></pre><p>The POST payload will be:</p><pre><code>productInterest=GC5000+series&amp;productInterest=GC3000+series</code></pre><p>HTML forms pass <code>+</code> for space. When constructing URLs by hand, you can use either <code>+</code> or <code>%20</code>: both are understood by URLSearchParams and any other URL parser worth its salt.</p><p>[2] If a string field is <em>always </em>read/written as a single value, then there’s no problem if it has a literal <code>;</code>.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Determine if a JS variable is a Marketo form object vs. HTML ˂form˃ vs. other form-ish things ]]></title>
        <description><![CDATA[ My latest UTM attribution library — my 5th go-round after Conversion Path, NanoTracker, and a couple of private ones! — manages hidden fields on Marketo forms, raw HTML &lt;form&gt;s, and a couple of other form types.

That means switching between different APIs:

if( type === &quot;marketoForms2&quot; ) {
  // call ]]></description>
        <link>https://blog.teknkl.com/determine-if-a-js-variable-is-a-marketo-form-object-vs-html-form-vs-other-form-ish-things/</link>
        <guid isPermaLink="false">6795cf2a67daf20001585605</guid>
        <category><![CDATA[ JS ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Thu, 27 Feb 2025 00:00:00 -0500</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/02/js.png" medium="image"/>
        <content:encoded><![CDATA[ <p>My latest UTM attribution library — my 5th go-round after Conversion Path, NanoTracker, and a couple of private ones! — manages hidden fields on Marketo forms, raw HTML <code>&lt;form&gt;</code>s, and a couple of other form types.</p><p>That means switching between different APIs:</p><pre><code class="language-javascript">if( type === "marketoForms2" ) {
  // call form.addHiddenFields()
} else if( type === "genericHTML" ) {
  // upsert `&lt;input type="hidden"&gt;` and set value
} else if( type === "otherLibrary" ) {
  // use that library’s JS API
}</code></pre><p>Admittedly, the <em>easy </em>way is to require a type hint, like:</p><pre><code class="language-javascript">updateHiddens( { form: obj, type: "marketoForms2" } );</code></pre><p>Or maybe just:</p><pre><code class="language-javascript">updateHiddens( { marketoForms2: obj } );</code></pre><p>But those are extra work for the end user, and I was sure the library could figure out on its own, letting you do:</p><pre><code>updateHiddens( obj );</code></pre><p>Adding more requirements to be flexible-slash-professional:</p><ol><li><strong>must work across Window objects</strong> — that is, a Marketo form in a child IFRAME must be detected as well</li><li><strong>cannot rely on HTML attributes</strong> like <code>class="mktoForm"</code> or <code>id="mktoForm_*"</code> because a Marketo form still works even if those are removed after loading<a href="#notes" rel="noreferrer">[1]</a></li><li><strong>cannot rely solely on familiar JS functions </strong>being <em>present</em>, though functions may be <em>called </em>as an aid</li><li><strong>may assume a global variable named <code>MktoForms2</code></strong> is the Marketo Forms 2.0 library</li><li><strong>need not defend against </strong><em>deliberate </em>attempts to fool the logic, i.e. expects to be called in good faith</li></ol><h2 id="what-is-a-%E2%80%9Cmarketo-form-object%E2%80%9D-anyway">What <em>is </em>a “Marketo form object”, anyway?</h2><p>The argument passed to <code>MktoForms2.whenReady()</code> and several other Forms 2.0 methods is a Marketo form object. It’s a <mark>generic JS Object</mark> with function properties like <code>setValues()</code>,  <code>addHiddenFields()</code>, <code>getId()</code>, and <code>getFormElem()</code>.</p><p>Because it’s just an Object, rather than being an instance of a custom class, it doesn’t have a self-describing type. But we can look deeper to unravel whether it was generated by the Forms 2.0 library, or whether it’s merely an object with the same named properties.</p><p>Calling <code>getFormElem()</code> returns a <em>jQuery wrapper </em>containing the native <code>&lt;form&gt;</code> element at <code>[0]</code>. (That’s why you’ll see <code>formEl = form.getFormElem()[0]</code> in a lot of my code, because I prefer native DOM methods whenever possible.) </p><p>Every jQuery object is an instance of the jQuery library, and Marketo uses its own internal copy of jQuery: <code>MktoForms2.$</code>.  Aha! That’s a simple way to know if Marketo created the form object:</p><pre><code class="language-javascrpipt">if( form.getFormElem() instanceof MktoForms2.$ ) { 
  // …
}</code></pre><p>We’d need to flesh that out to detect if the global forms library exists:</p><pre><code class="language-javascript ">if( window.MktoForms2.$ &amp;&amp; form.getFormElem() instanceof MktoForms2.$ ) { 
  // …
}</code></pre><p>And also should check if <code>getFormElem()</code> is callable:</p><pre><code class="language-javascript">if( Object.prototype.toString.call(form.getFormElem) == "[object Function]" &amp;&amp; window.MktoForms2.$ &amp;&amp; form.getFormElem() instanceof MktoForms2.$ ) { 
  // …
}</code></pre><p>(You’ll usually see <code>typeof form.getFormElem == "function"</code> but I’m using a more formal approach to be consistent with other stuff later.)</p><p>So far, so good. But remember requirement #1 above: the function needs to detect if <code>form</code> is a Marketo form object <mark>even if it’s owned by another window, i.e. passed between an IFRAME and its parent</mark>.</p><h2 id="detecting-a-marketo-form-object-from-another-window">Detecting a Marketo form object from another window</h2><p><code>MktoForms2</code> and your form may live inside a same-origin IFRAME, while the parent window only loads Munchkin.<a href="#notes" rel="noreferrer">[2]</a> In this case, <code>window.MktoForms2</code> will always be <code>undefined</code> on the parent, though the parent and child can share form objects across the window boundary.<a href="#notes" rel="noreferrer">[3]</a></p><p>So how do we solve for that? With a couple of DOM techniques you’ve probably never seen before, but which have been around for ages. </p><p>Every DOM Node, even one that isn’t currently <a href="https://developer.mozilla.org/en-US/docs/Web/API/Node/isConnected?ref=blog.teknkl.com" rel="noreferrer">connected</a> to a document, has an <a href="https://developer.mozilla.org/en-US/docs/Web/API/Node/ownerDocument?ref=blog.teknkl.com" rel="noreferrer"><code>ownerDocument</code></a> property. That’s the document context in which it was originally created, e.g. <code>someDocument.createElement("thing")</code> sets <code>ownerDocument</code> to <code>someDocument</code>.</p><p>In turn, every document has a <a href="https://developer.mozilla.org/en-US/docs/Web/API/Document/defaultView?ref=blog.teknkl.com" rel="noreferrer"><code>defaultView</code></a>, which is its host window.</p><p>So if we unwrap the native <code>&lt;form&gt;</code> element inside the jQuery object from <code>form.getFormElem()</code>, we can get its document, get that document’s window, and check for <code>MktoForms2</code> on the window. Whew!</p><p>Behold <code>getFormDuckType()</code>:<a href="#notes" rel="noreferrer">[4]</a></p><pre><code class="language-javascript">function getFormDuckType( form ) {
  
    if ( Object.prototype.toString.call(form.getFormElem) == "[object Function]" ) {
        const formElements = form.getFormElem(),
              MktoForms2Lib = formElements?.[0]?.ownerDocument?.defaultView?.MktoForms2;

        if( MktoForms2Lib &amp;&amp; formElements instanceof MktoForms2Lib.$ ) {
           return "marketoForms2";
        }
    }
  
    if( Object.prototype.toString.call(form) == "[object HTMLFormElement]" ) {
        return "genericHTML";
    }

    /* 
    … other conditions…  
    */
}</code></pre><h2 id="caveat-don%E2%80%99t-be-deliberately-foolish">Caveat: don’t be deliberately foolish!</h2><p>As noted in requirement #5, I trust you won’t <em>purposely </em>try to fool the type detection code.</p><p>Like don’t use<a href="https://developer.mozilla.org/en-US/docs/Web/API/Document/adoptNode?ref=blog.teknkl.com" rel="noreferrer"><code>adoptNode()</code></a> to move the underlying <code>&lt;form&gt;</code> element to another <code>ownerDocument</code>, or rename <code>MktoForms2</code>, or other stuff that breaks the Marketo form, just for the satisfaction of seeing the function fail!</p><p>But if you do encounter real-world scenarios where it doesn’t work, please let me know.</p><h5 id="notes">Notes</h5>
<p>[1] OK, it’s pretty unlikely that you’d remove the <code>class</code>, though removing the <code>id</code> helps when you have multiple Marketo forms on the same page.</p>
<p>[2] Not a recommended setup, but still out there.</p><p>[3] Talking about direct access here, e.g. via <code>window.parent.getFormDuckType(form)</code> not via <code>postMessage</code>. With <code>postMessage</code> you can’t send Marketo form objects <em>or </em>HTMLFormElements because they can’t be <a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm?ref=blog.teknkl.com#supported_types" rel="noreferrer"><code>structuredClone</code></a>-d.</p><p>[4] <a href="https://en.wikipedia.org/wiki/Duck_typing?ref=blog.teknkl.com">https://en.wikipedia.org/wiki/Duck_typing</a></p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Don’t start your LP domain with “ads” (i.e. ads.example.com)! ]]></title>
        <description><![CDATA[ Some things you only learn at the School of Hard MOps. ]]></description>
        <link>https://blog.teknkl.com/dont-use-ads-ads-example-com-as-your-lp-subdomain/</link>
        <guid isPermaLink="false">67a3dbbca815830001f852ff</guid>
        <category><![CDATA[ ad blockers ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Fri, 21 Feb 2025 01:05:00 -0500</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/02/browserandurlstuff.png" medium="image"/>
        <content:encoded><![CDATA[ <p>When we heard a client’s Marketo forms weren’t showing for users of AdBlock Plus, our first thought was they were embedding forms using the old <code>marketo.com</code> domain, which is blocked by privacy plugins.</p><p>But no, that wasn’t it: the forms were on Marketo LPs, not embeds, and they were using a proper LP domain. (Also, for the record, <mark>AdBlock Plus doesn’t care about <code>marketo.com</code></mark>. Only Firefox Tracking Protection and equivalent plugins care about that.) </p><p>So what’s the deal? Well, here’s what you see in the console in Chrome:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/02/2025-02-05-19_00_29-SiriusXM-Advertising-Playbook.png" class="kg-image" alt="" loading="lazy" width="1487" height="193" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/02/2025-02-05-19_00_29-SiriusXM-Advertising-Playbook.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/02/2025-02-05-19_00_29-SiriusXM-Advertising-Playbook.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/02/2025-02-05-19_00_29-SiriusXM-Advertising-Playbook.png 1487w" sizes="(min-width: 720px) 720px"></figure><p>Pretty standard, just means a 3rd-party plugin did the blocking. Looking at the AdBlock-injected console tab shows the deeper reason: </p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/02/2025-02-05-19_03_53-SiriusXM-Advertising-Playbook.png" class="kg-image" alt="" loading="lazy" width="2000" height="352" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/02/2025-02-05-19_03_53-SiriusXM-Advertising-Playbook.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/02/2025-02-05-19_03_53-SiriusXM-Advertising-Playbook.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1600/2025/02/2025-02-05-19_03_53-SiriusXM-Advertising-Playbook.png 1600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/02/2025-02-05-19_03_53-SiriusXM-Advertising-Playbook.png 2201w" sizes="(min-width: 720px) 720px"></figure><p>Now that’s interesting. See, the client is a media company, so naturally <mark>their B2B product is ad time</mark>. Their LP domain is <code>https://ads.examplemedia.com</code>, which fits the business. </p><p>Problem: AdBlock Plus, uBlock Origin, and other plugins use the <a href="https://easylist-downloads.adblockplus.org/v3/full/easylist.txt?ref=blog.teknkl.com" rel="noreferrer">EasyList</a> crowdsourced database to determine what’s an ad. And that database has the following long-winded <a href="https://github.com/easylist/easylist/blob/6c4ad696cd65bd4ef1f8470c5869629fd236dd4d/easylist/easylist_general_block.txt?ref=blog.teknkl.com#L7288" rel="noreferrer">blocking rule</a>, using EasyList’s proprietary syntax:</p><pre><code class="language-markup">://ads.$~image,~xmlhttprequest,domain=~ads.8designers.com|~ads.ac.uk|~ads.adstream.com.ro|~ads.allegro.pl|~ads.am|~ads.amazon|~ads.apple.com|~ads.atmosphere.copernicus.eu|~ads.band|~ads.bestprints.biz|~ads.bikepump.com|~ads.brave.com|~ads.buscaempresas.co|~ads.business.bell.ca|~ads.cafebazaar.ir|~ads.chewy.com|~ads.colombiaonline.com|~ads.comeon.com|~ads.cvut.cz|~ads.doordash.com|~ads.dosocial.ge|~ads.dosocial.me|~ads.elevateplatform.co.uk|~ads.finance|~ads.google.cn|~ads.google.com|~ads.gree.net|~ads.gurkerl.at|~ads.harvard.edu|~ads.instacart.com|~ads.jiosaavn.com|~ads.kaipoke.biz|~ads.kazakh-zerno.net|~ads.kifli.hu|~ads.knuspr.de|~ads.listonic.com|~ads.luarmor.net|~ads.magalu.com|~ads.mercadolivre.com.br|~ads.mgid.com|~ads.microsoft.com|~ads.midwayusa.com|~ads.mobilebet.com|~ads.mojagazetka.com|~ads.msstate.edu|~ads.mst.dk|~ads.mt|~ads.nc|~ads.nipr.ac.jp|~ads.olx.pl|~ads.pinterest.com|~ads.remix.es|~ads.rohlik.cz|~ads.rohlik.group|~ads.route.cc|~ads.safi-gmbh.ch|~ads.scotiabank.com|~ads.selfip.com|~ads.shopee.cn|~ads.shopee.co.th|~ads.shopee.com.br|~ads.shopee.com.mx|~ads.shopee.com.my|~ads.shopee.kr|~ads.shopee.ph|~ads.shopee.pl|~ads.shopee.sg|~ads.shopee.tw|~ads.shopee.vn|~ads.smartnews.com|~ads.snapchat.com|~ads.socialtheater.com|~ads.spotify.com|~ads.studyplus.co.jp|~ads.taboola.com|~ads.tiktok.com|~ads.tuver.ru|~ads.twitter.com|~ads.typepad.jp|~ads.us.tiktok.com|~ads.viksaffiliates.com|~ads.vk.com|~ads.watson.ch|~ads.x.com|~ads.yandex|~reempresa.org</code></pre><p>That rule means:</p><ul><li>block all embedded content from domains starting with <code>ads.</code>, <em>unless </em><ul><li>the content is a static image, <em>or</em></li><li>the content is an Ajax/XHR/Fetch request, <em>or</em></li><li>the domain is a known exception: <code>ads.8designers.com</code>, <code>ads.ac.uk</code>, etc.</li></ul></li></ul><p>A <code>&lt;script&gt;</code> tag, of course, isn’t an image or Ajax. And <code>ads.examplemedia.com</code> isn’t in the exception list. So <code>&lt;script src="https://ads.examplemedia.com/…/forms2.min.js"&gt;</code> is blocked.</p><p>The long-term solution is to change domains: something like <code>brands.examplemedia.com</code> or <code>business.examplemedia.com</code> gets the point across as well as <code>ads</code>.</p><p>The short-term solution the client pursued was to ask EasyList to add them to that long list of exceptions — domains that <em>market </em>ad space/ad time, but aren’t themselves ads. The EasyList folks were attentive, and now they’re on the exception list. But better to not have to worry about that!</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ The rarely needed (but still important) “offSuccess” un-handler ]]></title>
        <description><![CDATA[ Forms 2.0 events (onSubmit, onSuccess, onValidate) have a key difference from pure DOM events (&lt;form&gt; submit, &lt;a&gt; click, etc.). ]]></description>
        <link>https://blog.teknkl.com/the-rarely-needed-but-still-important-offsuccess-un-handler/</link>
        <guid isPermaLink="false">67b6424e7562d500010bd618</guid>
        <category><![CDATA[ Marketo forms ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Thu, 20 Feb 2025 00:44:27 -0500</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/02/formsjs-2.png" medium="image"/>
        <content:encoded><![CDATA[ <p><strong><em>“We’re seeing duplicate GTM Data Layer updates for a single Marketo form fill,” </em></strong>read the trouble ticket. <strong><em>“Sometimes even 3 or 4 updates.”</em></strong></p><p>My mind went to adding multiple <code>onSuccess</code> listeners. Sure enough, they had a setup like this:</p><pre><code class="language-javascript">function gtmAfterSubmit(submittedValues){
  dataLayer.push({
    event: "marketoFormSubmit",
    form: submittedValues.formid
  });
});

MktoForms2.whenRendered(function(mktoForm){
  mktoForm.onSuccess(gtmAfterSubmit);
});  </code></pre><p>Spot the problem?</p><p>It’s the use of <code>whenRendered</code>, which <mark>fires every time form elements are injected by Visibility Rules</mark>. They had some VRs in place (Country ⮕ State dependency, business vs. personal fieldsets) so the form rendered more than once. Each render meant another <code>gtmAfterSubmit()</code> pushed onto the <code>onSuccess</code> stack. Once the form submitted successfully, they all ran.</p><p>The fix is to use <code>whenReady</code>. To be clear, <code>whenRendered</code> is key to a lot of custom form behaviors — we more often see people use <code>whenReady</code> when they <em>should be </em>using <code>whenRendered</code> than the reverse! But here you indeed want <code>whenReady</code>.</p><h2 id="why-aren%E2%80%99t-forms-20-listeners-deduped">Why aren’t Forms 2.0 listeners deduped?</h2><p>Problem solved. But the client was still curious: <strong><em>“Shouldn’t the listener only be added once, since it’s a reference to the same function?”</em></strong></p><p>“Shouldn’t” is subjective, of course. But I saw where they were coming from. <mark>With JavaScript’s native <code>addEventListener</code>, if you already added a listener function or object,</mark><a href="#notes" rel="noreferrer"><mark>[1]</mark></a><mark> it won’t be added again.</mark> </p><p>So in this example, where native <code>XMLHttpRequest</code> is used to submit a custom form, <code>gtmAfterSubmit()</code> will only run once, even though it’s seemingly added 2 times:</p><pre><code class="language-javascript">function gtmAfterSubmit(e){
  dataLayer.push({
    event: "formSubmit",
    form: form.id
  });
}

const ajaxSubmitter = new XMLHttpRequest;
ajaxSubmitter.addEventListener("load", gtmAfterSubmit);

/* … other stuff… */

ajaxSubmitter.addEventListener("load", gtmAfterSubmit);</code></pre><p>The Marketo Forms 2.0 API doesn’t work that way. <mark>Every time you call <code>onSuccess</code>, the listener function is added, regardless of whether it was already there.</mark> In essence, Marketo uses an <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array?ref=blog.teknkl.com" rel="noreferrer">Array</a> for its stack, while native JS uses a <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set?ref=blog.teknkl.com" rel="noreferrer">Set</a>.  </p><h2 id="offsuccess-prevents-duplicates"><code>offSuccess</code> prevents duplicates </h2><p>Though the fix here is to switch to <code>whenReady</code>, you might encounter another situation where you aren’t 100% sure if a listener was already added, e.g. when <code>whenReady</code> is used by multiple authors.</p><p>In that case, call <code>offSuccess</code> right before <code>onSuccess</code>. <mark><code>offSuccess</code> removes the listener if it was already there, and otherwise does nothing.</mark> So with this code, <code>gtmAfterSubmit()</code> will only run once because we preemptively remove it:  </p><pre><code class="language-javascript">function gtmAfterSubmit(submittedValues){
  dataLayer.push({
    event: "marketoFormSubmit",
    form: submittedValues.formid
  });
});

MktoForms2.whenReady(function(mktoForm){
  mktoForm.offSuccess(gtmAfterSubmit);
  mktoForm.onSuccess(gtmAfterSubmit);
});  

/* … other stuff… */

MktoForms2.whenReady(function(mktoForm){
  mktoForm.offSuccess(gtmAfterSubmit);
  mktoForm.onSuccess(gtmAfterSubmit);
});</code></pre><p>And in case you’re wondering: nope, it’s not possible for anything to happen in-between the removing and adding because of JS’s <a href="https://262.ecma-international.org/6.0/?ref=blog.teknkl.com#sec-jobs-and-job-queues:~:text=Once%20execution%20of%20a%20Job%20is%20initiated,%20the%20Job%20always%20executes%20to%20completion.%20No%20other%20Job%20may%20be%20initiated%20until%20the%20currently%20running%20Job%20completes." rel="noreferrer">run to completion guarantee</a>.</p><h3 id="notes">Notes</h3><p>[1] You also have to pass also the same options, like <code>useCapture</code>.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ FlowBoost can check if a Data Value Change was a meaningful change, or just a case change ]]></title>
        <description><![CDATA[ Is a change from “SANDY@EXAMPLE.COM” to “sandy@example.com” worth triggering on? Yeah, didn’t think so! ]]></description>
        <link>https://blog.teknkl.com/checking-if-a-data-value-change-was-real-or-just-a-change-of-case-2/</link>
        <guid isPermaLink="false">67a5b51862eb8f0001ade600</guid>
        <category><![CDATA[ FlowBoost ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Sun, 09 Feb 2025 13:35:30 -0500</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/02/flowboostjs_new.png" medium="image"/>
        <content:encoded><![CDATA[ <p>Marketo uses exact string comparison (case-sensitive and accent-sensitive) to determine if fields have changed. Overall, this is good: otherwise, we’d never know if enrichment/normalization APIs were working.</p><p>Problem: changes to key fields like <em>Email Address </em>(which <a href="https://blog.teknkl.com/email-addresses-case-sensitive-but-anyway/" rel="noreferrer">teeeeechnically is case-sensitive</a> but must be treated as case-insensitive) might trigger alerts to sales or other high-priority actions. You don't want a false alarm just because someone decided to <s>boomerize</s> capitalize their email: </p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/02/image-1.png" class="kg-image" alt="" loading="lazy" width="2000" height="243" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/02/image-1.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/02/image-1.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1600/2025/02/image-1.png 1600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w2400/2025/02/image-1.png 2400w" sizes="(min-width: 720px) 720px"></figure><p>Solution: in modern JS, and therefore in FlowBoost, you can do string comparison at any level of sensitivity.</p><p>First, create a custom Email type field <em>Previous Email Address</em>. That's what we'll use to store the old value to compare to the new value to see if it was a "real" change at a business level.</p><p>Then create this FlowBoost ’hook and trigger it on Data Value Changes for <em>Email</em>:</p><pre><code class="language-javascript">let oldValue = {{lead.Previous Email Address}};
let newValue = {{lead.Email Address}};

let baseLetterComparator = Intl.Collator(undefined, { sensitivity: "base" } );

effectiveDataValueChange = baseLetterComparator.compare(oldValue, newValue) == 0 ? false : true;
previousEmailAddress = newValue;
</code></pre><p>That’ll return a JSON response like this:</p><pre><code class="language-javascript">{
  "effectiveDataValueChange": true,
  "previousEmailAddress": "SANDY+TEST@ExAmPlE.cOm"
}</code></pre><p>Now you can trigger on <em>Webhook is Called </em>to check if the change was more significant than just case or accent (constrain on <em>Response contains</em> <code>"effectiveDataValueChange": true</code>).</p><p>And in the webhook Response Mappings, map <code>previousEmailAddress</code> to <em>Previous Email Address. </em>That way, <em>after</em> the comparison, the latest value is saved in <em>Previous, </em>ready for the next comparison.</p><h2 id="how-it-works">How it works</h2><p>To get a non-default<a href="#notes" rel="noreferrer">[1]</a> instance of <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Collator/Collator?ref=blog.teknkl.com" rel="noreferrer"><code>Intl.Collator</code></a>, you pass in a locale (here <code>undefined</code> to use the system locale, <code>en-us</code>) and an options object (here <code>{ sensitivity: "base" }</code>.</p><p>The <code>sensitivity</code> option indicates <mark>the most granular difference that’s considered significant</mark>.  So <code>sensitivity: "base"</code> means if and only if base letters (like <code>a</code> and <code>z</code>) are different, 2 characters are different. Other differences are not</p><p>(In contrast, <code>sensitivity: "case"</code> would mean if case is different, the strings are different — which we don’t want. Yes, it’s confusing, and I find myself re-learning it from time to time!)</p><p>Notably, <code>Intl.Collator.compare()</code> is <em>a sorting function. </em>It’s not exclusively a yes/no comparison. If <code>compare()</code> returns <code>0</code>, the two strings would sort at the same position, i.e. are <mark><em>equivalent </em>using the given collation.</mark> Perfect for this use case, even if it sounds a little strange.</p><h3 id="notes">Notes</h3><p>[1] A default instance uses <a href="https://tc39.es/ecma402/?ref=blog.teknkl.com#sec-intl.collator:~:text=Set%20sensitivity%20to%20%22variant%22" rel="noreferrer"><code>"sensitivity": "variant"</code></a>, which treats all possible differences as significant. Basically the same way <em>Data Value Changes </em>works, and what we’re avoiding.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Manage form field visibility based on Marketo segmentations ]]></title>
        <description><![CDATA[ Hide a field for customers, show a field only to resellers, and so on — without creating multiple forms. ]]></description>
        <link>https://blog.teknkl.com/form-field-visibility-based-on-segmentations/</link>
        <guid isPermaLink="false">67770d133af5fe00018b0665</guid>
        <category><![CDATA[ pseudotypes ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Fri, 17 Jan 2025 01:13:01 -0500</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/01/segmentation_and_dc_stuff_v2.png" medium="image"/>
        <content:encoded><![CDATA[ <p>You did the right thing: you reduced 100s of unmaintainable forms to a handful of global forms. You use native Visibility Rules to show/hide fields based on user input.</p><p>Then your boss throws a curveball: <strong><em>“Can we show different fields if we already know somebody’s segment(s)?”</em></strong></p><p>Uh-oh. Segmentations aren’t available as form fields. Makes sense, since <mark>they're system-managed and a person can never change their own segment</mark>. But the fact they're not available in Form Editor means you can't use them in VRs. </p><p>You can of course <mark>make the entire form element dynamic.</mark> But then you're back to cloning forms, recreating your maintainability nightmare.</p><p>But fret not, it’s easy to use segments in Visibility Rules. There’s just a little bit of back-end prep and front-end JS.</p><h2 id="create-a-front-end-field">Create a front-end field</h2><p>Create a new field as shown below. With my clients, I refer to such fields by their “pseudotype”: they’re <em>form visibility manager fields</em><a href="#notes" rel="noreferrer">[1]</a><em> </em>and we create one or two in every instance. In the Marketo database, its datatype is simply <em>string</em>, but to convey its special use we use the pseudotype.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/01/image.png" class="kg-image" alt="" loading="lazy" width="837" height="510" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/01/image.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/01/image.png 837w" sizes="(min-width: 720px) 720px"></figure><p>You’ll use that field to trigger Visibility Rules, adding it to your form as Hidden w/Pre-Fill off:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/01/image-1.png" class="kg-image" alt="" loading="lazy" width="1050" height="738" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/01/image-1.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/01/image-1.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/01/image-1.png 1050w" sizes="(min-width: 720px) 720px"></figure><p>Now, let’s figure out how to <mark>set <em>Form Visibility Manager A</em> to the person’s segment</mark>. You can run with native VRs from there.</p><h2 id="add-two-mktotext-elements-to-the-template">Add two <code>mktoText</code> elements to the template</h2><p>Add these elements right before the closing <code>&lt;body&gt;</code> tag:</p><pre><code class="language-markup">&lt;div class="mktoText" id="exportedMarketoFields" mktoName="Exported Marketo Fields"&gt;&lt;/div&gt;
&lt;div class="mktoText" id="formBehaviorsJS" mktoName="Form Behaviors JS"&gt;&lt;/div&gt; </code></pre><p><em>Exported Marketo Fields </em>holds the main magic. You’ll make it dynamic in LP editor.  </p><p><em>Form Behaviors JS </em>is static. <mark>And if you already have a container for page-level forms JS (you should have one on every template, IMO!)</mark> just append the JS code to that one.</p><h2 id="segment-and-populate-exported-marketo-fields">Segment and populate <em>Exported Marketo Fields</em></h2><p>In LP Editor, right-click and make <em>Exported Marketo Fields </em>dynamic. In this example, we’re segmenting by the segmentation called <em>Channel, </em>which has three named segments plus the default segment.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/01/image-10.png" class="kg-image" alt="" loading="lazy" width="558" height="483"></figure><p>For each segment, open the HTML Source Editor and use a variation of the following code, with the segmentation as the <code>&lt;option label&gt;</code> and the segment name as the <code>&lt;option&gt;</code> text:</p><pre><code class="language-markup">&lt;datalist class="mktoTokens"&gt;
&lt;option label="Lead.Channel"&gt;Default&lt;/option&gt;
&lt;/datalist&gt;</code></pre><p>That is, for the segment “Individual Investor”<em>, </em>use:</p><pre><code class="language-markup">&lt;datalist class="mktoTokens"&gt;
&lt;option label="Lead.Channel"&gt;Individual Investor&lt;/option&gt;
&lt;/datalist&gt;</code></pre><p>For the segment “RIA”, use:</p><pre><code class="language-markup">&lt;datalist class="mktoTokens"&gt;
&lt;option label="Lead.Channel"&gt;RIA&lt;/option&gt;
&lt;/datalist&gt;</code></pre><p>And so on.</p><h2 id="populate-form-behaviors-js">Populate <em>Form Behaviors JS</em></h2><p>Next, add a bit of Forms 2.0 JS to read the value from the matching <code>&lt;option&gt;</code> and write it to the hidden form field:</p><pre><code class="language-javascript">&lt;script&gt;
document.addEventListener("DOMContentLoaded", function(e){
  MktoForms2.whenReady(function(readyForm){
    readyForm.setValues({
      formVisibilityManagerA: document.querySelector(`datalist.mktoTokens [label="Lead.Channel" i]`)?.value;
    });
  });
});
&lt;/script&gt;</code></pre><p>(I shared a general helper function <code>getExportedMktoTokens</code> in <a href="https://blog.teknkl.com/to-safely-use-lead-tokens-in-js-you-must-output-them-in-an-html-element-first/" rel="noreferrer">a companion post</a>, but you can use this targeted code for now.)</p><p>And that’s it, now you’re ready to use Visibility Rules based on <em>Form Visibility Manager A!</em></p><h2 id="wait-are-you-just-not-gonna-talk-about-the-datalist">Wait. Are you just not gonna talk about the <code>&lt;datalist&gt;</code>?</h2><p>I talk about it at length <a href="https://blog.teknkl.com/to-safely-use-lead-tokens-in-js-you-must-output-them-in-an-html-element-first/" rel="noreferrer">here</a>!😀 In that post, you’ll learn why <mark>you must “export” <code>{{lead.tokens}}</code> and <code>{{company.tokens}}</code> as above to safely use them in JavaScript</mark>, because they are encoded for an HTML text context, <mark>not for a JS string context. </mark></p><p>In other words, <strong>never do </strong><code>let string = "{{Lead.String Field}}"</code>. That can lead to fatal JS errors. You may not see them in casual testing, but your leads will feel the pain.</p><h5 id="notes">Notes</h5>
<p>[1] Pseudotypes also include <em>history fields</em>, <em>multivalued fields, </em>and <em>proxy fields. </em>All have basic types under the hood, but a functional layer on top makes them special.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ To use Marketo {{lead.tokens}} in JS, output them into an HTML element, then read them back ]]></title>
        <description><![CDATA[ IMO, deeply understanding several text encodings is better than superficially knowing multiple languages. Here’s a case where using the wrong encoding can be bad or… really bad. ]]></description>
        <link>https://blog.teknkl.com/to-safely-use-lead-tokens-in-js-you-must-output-them-in-an-html-element-first/</link>
        <guid isPermaLink="false">678d7bfa15230c0001143cce</guid>
        <category><![CDATA[ html-encoding ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Thu, 16 Jan 2025 14:00:00 -0500</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/01/token_tricks_v2-2.png" medium="image"/>
        <content:encoded><![CDATA[ <p>No matter how tempting, <strong>never do this </strong>with Marketo string fields:</p><pre><code class="language-javascript">let jsVar = "{{lead.Some Field}}";</code></pre><p>Why? Because <code>{{lead.tokens}}</code> <mark>are HTML-encoded, not JS-encoded</mark>. That is, they’re <mark>only supported in text content</mark>, like so:</p><pre><code class="language-markup ">&lt;div&gt;{{lead.Some Field}}&lt;/div&gt;</code></pre><p>Failing to account for this will lead to broken pages — and you won’t find the bugs during testing unless you create a wide enough range of test values.</p><p>The solution, as I previewed in <a href="https://blog.teknkl.com/form-field-visibility-based-on-segmentations/" rel="noreferrer">the recent post</a> on form field visibility, is to output tokens into HTML elements and read them using JS. Simple code for this is below. But first, a little encoding review.</p><h2 id="what-is-html-encoding">What is HTML encoding?</h2><p>Some characters have special meaning in HTML markup. Most important are the ubiquitous <code>&lt;</code> and <code>&gt;</code> (used to denote HTML tags) and <code>"</code> and <code>'</code> (used to quote HTML attributes, as in <code>&lt;div class="whatever"&gt;</code>). </p><p>When these characters should be treated as mere text (i.e. as non-special) they must be encoded. Otherwise, you’ll get broken HTML. And even worse, you can be vulnerable to <a href="https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html?ref=blog.teknkl.com#output-encoding-for-html-contexts" rel="noreferrer">XSS attacks</a>.</p><p>You’ve surely seen HTML encoding before: <code>&amp;lt;</code> for <code>&lt;</code>, <code>&amp;gt;</code> for <code>&gt;</code>, and <code>&amp;nbsp;</code> for a <a href="https://en.wikipedia.org/wiki/Non-breaking_space?ref=blog.teknkl.com" rel="noreferrer">non-breaking space</a>. In fact, <em>any character </em>can be HTML-encoded using its numeric codepoint, but for readability it’s best to encode only those that can’t be used literally.<a href="#notes" rel="noreferrer">[1]</a> For example, take this raw unencoded string:</p><pre><code class="language-markup">My name is "Joe"</code></pre><p>Encoding only the mandatory <code>"</code>, it remains readable:</p><pre><code class="language-markup">My name is &amp;quot;Joe&amp;quot;</code></pre><p>Whereas if you encode <em>everything</em>, the HTML source becomes unreadable (though it would display perfectly in the browser itself):</p><pre><code class="language-markup">&amp;#x4d;&amp;#x79;&amp;#x20;&amp;#x6e;&amp;#x61;&amp;#x6d;&amp;#x65;&amp;#x20;&amp;#x69;&amp;#x73;&amp;#x20;&amp;#x22;&amp;#x4a;&amp;#x6f;&amp;#x65;&amp;#x22;</code></pre><p>Marketo uses minimal encoding like PHP’s <a href="https://www.php.net/manual/en/function.htmlspecialchars.php?ref=blog.teknkl.com" rel="noreferrer">htmlspecialchars()</a>, so just the 4 characters <code>&lt;</code> <code>&gt;</code> <code>"</code> <code>&amp;</code> are included. This works perfectly for text content. But here’s the rub: <mark>code in a <code>&lt;script&gt;</code> isn’t text content.</mark> It needs a different type of encoding.</p><h2 id="what-is-js-encoding">What is JS encoding?</h2><p>Encoding for JS is strikingly different from HTML encoding.</p><ul><li>some characters happen to be special in both contexts, but they can’t be encoded <em>the same way </em>in JS </li><li>some characters <em>aren’t </em>special in HTML but <em>are </em>special in JS and must be encoded</li><li>some characters <em>are </em>special in HTML but <em>aren’t </em>in JS, so they must <em>not</em> be encoded </li><li>there are even different encoding rules in JS for the same character </li></ul><p>For one example, the double quotation mark <code>"</code> is special in both HTML and JS. But you encode it as <code>\"</code> in a JS string, not as <code>&amp;quot;</code>. If you encode the wrong way, your code won’t break at the syntax level, but it will produce poor results. <code>My name is "Joe"</code> will be shown as <code>My name is &amp;quot;Joe&amp;quot;</code> and at the very least look unprofessional.</p><p>For another, the ampersand <code>&amp;</code> is special in only HTML. If you output it as <code>&amp;amp;</code> in JS, you get those 5 literal characters <code>&amp;</code> <code>a</code> <code>m</code> <code>p</code> <code>;</code> as they have no special meaning. The end user sees <code>Tom &amp;amp; Jerry</code> instead of <code>Tom &amp; Jerry</code>.</p><p>For yet another, a line break isn’t special in HTML. But in JS it’s extra special. Inside a double- or single-quoted string, it <em>must </em>be encoded as <code>\n</code>. While inside a backtick-quoted string, you <em>may </em>encode, but it’s optional. And outside of strings, you <em>must not </em>encode it. Confusing, eh?</p><p>In sum: <mark>you can’t output generic HTML-encoded text into a <code>&lt;script&gt;</code>.</mark><a href="#notes" rel="noreferrer">[2]</a></p><h2 id="solution-output-into-datalistoption-elements">Solution: output into <code>&lt;datalist&gt;&lt;option&gt;</code> elements</h2><p>The right move is to output <code>{{lead.tokens}}</code> into a hidden element, then read them out using JS. This eliminates any encoding worries because JS sees the <em>decoded </em>value.</p><p>Technically, any element will do, but I find the perfect fit is a <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Element/datalist?ref=blog.teknkl.com" rel="noreferrer"><code>&lt;datalist&gt;</code></a> with nested <code>&lt;options&gt;</code>:</p><pre><code class="language-markup">&lt;datalist class="mktoTokens"&gt;
&lt;option label="lead.Channel"&gt;{{lead.Channel}}&lt;/option&gt;
&lt;option label="lead.Country"&gt;{{lead.Country}}&lt;/option&gt;
&lt;/datalist&gt;</code></pre><p>A <code>&lt;datalist&gt;</code> is always hidden, and semantically it represents a list of data points. Close enough if you ask me!</p><p>As you can see, the <code>label</code> is the token name <em>without </em>the <code>{{</code> <code>}}</code>; the text is the token itself, <em>with </em>the <code>{{</code> <code>}}</code>).</p><p>(You can actually use any <code>label</code>, I just find it easier to be consistent.)</p><h2 id="reading-from-a-datalist-getexportedmktotokens">Reading from a <code>&lt;datalist&gt;</code>: <code>getExportedMktoTokens()</code></h2><p>Here’s a simple function <code>getExportedMktoTokens()</code> to fetch one or more tokens:  </p><pre><code class="language-javascript">function getExportedMktoTokens(tokenLabels){
   
   const stor = "datalist.mktoTokens option[label]";     
   const options = Array.from(document.querySelectorAll(stor));

   const ignoreCaseComparator = Intl.Collator(undefined, { sensitivity: "accent" });
   
   let results = [];
   for( const name of [tokenLabels].flat() ){ 
      const value = options.find( option =&gt; ignoreCaseComparator.compare(option.label, name) === 0 )?.value;
      results.push(value ?? null);
   }

   return Array.isArray(tokenLabels) ? results : results[0];
}</code></pre><p>You can call it with one token label or an array of labels:</p><pre><code class="language-javascript">let leadCountry = getExportedMktoTokens("lead.Country");</code></pre><pre><code>let [leadCountry, leadChannel] = getExportedMktoTokens(["lead.Country","lead.Channel"]);</code></pre><p>As you might’ve picked up, the <code>&lt;option&gt;</code> search is case-insensitive to align with how tokens work. So <code>getExportedMktoTokens("LEAD.COUNTRY")</code> will get the token in <code>&lt;option label="lead.Country"&gt;</code>.</p><p>That’s it! Now you’re ready to safely use Marketo tokens in JS.</p><p>P.S. A happy side effect is you can also reference tokens <mark>from external JS files</mark>, which otherwise can’t use tokens.  </p><h5 id="notes">Notes</h5>
<p>[1] You actually <em>can </em>use a literal NBSP in HTML if you copy and paste it from somewhere. It’s just impossible to distinguish from a regular space, so you’re likely to delete it accidentally. </p><p>[2] Theoretically, you can output variables into JS that pass through an HTML encoder if <mark>you know with 100.00% certainty they don’t contain any characters that are special in HTML <em>or </em>JS </mark>— that is, no HTML encoding will happen and they don’t contain any special JS characters. But you cannot know this with a Marketo string field!</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Automatically update images in Google Docs-based documentation ]]></title>
        <description><![CDATA[ This post isn’t martech-specific. But it applies to MOPS process guides, so figured it was worth sharing. ]]></description>
        <link>https://blog.teknkl.com/automatically-update-images-in-google-docs/</link>
        <guid isPermaLink="false">67789d1bdcb0f200017716bf</guid>
        <category><![CDATA[ Google Docs ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Sat, 04 Jan 2025 01:47:51 -0500</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/01/gdocs_stuff-4.png" medium="image"/>
        <content:encoded><![CDATA[ <p>Using a Google Doc to document internal processes (or external product docs, if you’re really daring) has the big plus of familiarity.</p><p>IMO, most everything else is a minus (which is why the awesome <a href="https://www.archbee.com/?ref=blog.teknkl.com" rel="noreferrer">Archbee</a> and other docs-focused platforms exist!). One minus you can at least work around is that <mark>images embedded in a Doc won’t auto-update, even if they were loaded from a remote URL</mark>.</p><p>That’s right, all 6 options here for <em>Insert » Image </em>result in a local copy of the image:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/01/image-6.png" class="kg-image" alt="" loading="lazy" width="946" height="542" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/01/image-6.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/01/image-6.png 946w" sizes="(min-width: 720px) 720px"></figure><p>Even the promising <em>By URL </em>doesn’t work like an HTML <code>&lt;img src&gt;</code> where updates to the remote file are picked up on reload. Instead, it just <mark>copies the file into the document that one time</mark>. To pick up changes, you need to delete the image and <em>Insert </em>again.</p><p>But there’s a better way via a bit of Apps (Java)Script.</p><p>Here’s the key to making the code work. When you insert an image, <em>also </em><mark>set its link to the image’s original URL</mark>. Here, I used <em>Insert » By URL </em>to grab the screenshot <code>https://blog.teknkl.com/content/images/example.png</code>. Then I click <em>Insert link </em>and set the same value <code>https://blog.teknkl.com/content/images/example.png</code>.<a href="#notes" rel="noreferrer">[1]</a></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/01/image-8.png" class="kg-image" alt="" loading="lazy" width="1030" height="1034" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/01/image-8.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/01/image-8.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/01/image-8.png 1030w" sizes="(min-width: 720px) 720px"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/01/image-7.png" class="kg-image" alt="" loading="lazy" width="863" height="1021" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/01/image-7.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/01/image-7.png 863w" sizes="(min-width: 720px) 720px"></figure><p>Now the code knows where to refresh the image from. We’ve gotta use the standard link attribute (<a href="https://developers.google.com/apps-script/reference/document/attribute?ref=blog.teknkl.com" rel="noreferrer"><code>LINK_URL</code></a> in Apps Script) because you can’t add arbitrary attributes to an <a href="https://developers.google.com/apps-script/reference/document/inline-image?ref=blog.teknkl.com" rel="noreferrer">InlineImage</a> element. (N.B. I’m no fan of Google Docs because of this lack of extensibility.)</p><h2 id="get-the-code">Get the code</h2><p>This Apps Script function uses the DOM-like API to (a) get every InlineImage<a href="#notes" rel="noreferrer">[2]</a> in the document, (b) fetch the most recent version from the link URL, and (c) swap the images at the same document position:</p><pre><code class="language-javascript">function refreshImages() {
  const doc = DocumentApp.getActiveDocument();
  const body = doc.getBody();
  const images = body.getImages();

  for( const originalImage of images ){
    const pseudoSource = originalImage.getAttributes().LINK_URL;
    
    if(pseudoSource) {
      const parentEl = originalImage.getParent();
      const imagePosition = parentEl.getChildIndex(originalImage);

      const resp = UrlFetchApp.fetch(pseudoSource);
      const newImageBlob = resp.getBlob(); 
      const newImage = parentEl.insertInlineImage(imagePosition,newImageBlob);

      newImage.setAttributes(originalImage.getAttributes());
      originalImage.removeFromParent();
    }
  }
}</code></pre><p>Set <code>refreshImages</code> to run <em>On open, </em>which includes refreshing the browser tab:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/01/image-9.png" class="kg-image" alt="" loading="lazy" width="1053" height="1009" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/01/image-9.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/01/image-9.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/01/image-9.png 1053w" sizes="(min-width: 720px) 720px"></figure><p>That’s it! Now it’s easier to keep screenshots, logos, etc. up to date.</p><h5 id="notes">Notes</h5>
<p>[1] The image therefore opens <em>its own source URL </em>on click; you can’t have it open a web page. A worthy tradeoff, in my opinion. If you truly must link elsewhere, you could embed the URL in the image’s <code>title</code> instead of the link. Use a consistent format like <code>&lt;friendly title&gt; (&lt;url&gt;)</code>, then <code>getAltTitle()</code> + regexp to parse it out.</p><p>[2] <a href="https://developers.google.com/apps-script/reference/document/positioned-image?ref=blog.teknkl.com" rel="noreferrer">PositionedImage</a> elements in a Google Doc — i.e. images with left and top offsets, like absolutely positioned images in HTML — don’t support links. If you wanted to include that type of image, you’d be forced to use the <code>title</code> as in [1].</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Gain instant lead insight with a DNS MX lookup ]]></title>
        <description><![CDATA[ Identifying email client(s) is easy enough after people open an email, but what about before? ]]></description>
        <link>https://blog.teknkl.com/gain-lead-insight-with-an-instant-mx-lookup/</link>
        <guid isPermaLink="false">6744e0b26b665d0001aec257</guid>
        <category><![CDATA[ DNS ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Mon, 30 Dec 2024 16:27:10 -0500</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/01/dns.png" medium="image"/>
        <content:encoded><![CDATA[ <p>The hard part of email design is cross-client compatibility. Enhanced effects work in <em>some </em>web and mobile clients, but you need graceful degradation to HTML 3.2-era stuff, gotta have VML for Outlook desktop, etc.</p><p>Identifying email clients is easy-ish after a person opens an email, but what about <em>before</em>?</p><p>Wouldn’t it be nice to have some idea of how many people will be reading your emails in at least one Gmail client — in addition to those literally <code>@gmail.com</code>? A Smart List like this’d do it:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/12/image-2.png" class="kg-image" alt="" loading="lazy" width="1043" height="573" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/12/image-2.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2024/12/image-2.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/12/image-2.png 1043w" sizes="(min-width: 720px) 720px"></figure><p>A similar list of people whose companies use Outlook 365, but who aren’t using personal <code>@outlook.com</code> addresses:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/12/image-3.png" class="kg-image" alt="" loading="lazy" width="1107" height="560" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/12/image-3.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2024/12/image-3.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/12/image-3.png 1107w" sizes="(min-width: 720px) 720px"></figure><p>And a fun one, people whose domains don’t have MX records at all, so can’t receive mail<a href="#notes" rel="noreferrer">[1]</a> (i.e. partial/poor man’s email validation):</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/12/2024-12-23-14_50_23-MX-Survey_-No-MX-Records---Database-and-23-more-pages---Personal---Microsoft--Ed.png" class="kg-image" alt="" loading="lazy" width="1109" height="562" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/12/2024-12-23-14_50_23-MX-Survey_-No-MX-Records---Database-and-23-more-pages---Personal---Microsoft--Ed.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2024/12/2024-12-23-14_50_23-MX-Survey_-No-MX-Records---Database-and-23-more-pages---Personal---Microsoft--Ed.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/12/2024-12-23-14_50_23-MX-Survey_-No-MX-Records---Database-and-23-more-pages---Personal---Microsoft--Ed.png 1109w" sizes="(min-width: 720px) 720px"></figure><p>Let’s find out how to populate that <em>Email Domain MX Records </em>field.</p><h2 id="create-marketo-fields">Create Marketo fields</h2><p>I assume you already have a field <em>Email Domain </em>that holds a person’s email domain. If not, add it as an SFDC Formula:</p><pre><code class="language-markup">if( find( '@',YourEmailField) == 0, '', right( YourEmailField, len(YourEmailField) - find( '@', YourEmailField ) ) )</code></pre><p>(Alternately, you could use FlowBoost to parse the domain, though at that point you might as well <a href="#or-use-flowboost" rel="noreferrer">use FlowBoost for the MX lookup</a>, too.) </p><p>Now add a String field <em>Email Domain MX Records:</em></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/12/image-1.png" class="kg-image" alt="" loading="lazy" width="640" height="378" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/12/image-1.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/12/image-1.png 640w"></figure><p>We’ll fill that field using Google’s <a href="https://developers.google.com/speed/public-dns/docs/doh/?ref=blog.teknkl.com" rel="noreferrer">DNS-over-HTTPS JSON gateway</a>, part of their infinitely scalable public DNS infrastructure.</p><h2 id="set-up-the-webhook">Set up the webhook</h2><p>Thanks to <a href="https://en.wikipedia.org/wiki/DNS_over_HTTPS?ref=blog.teknkl.com" rel="noreferrer">DNS-over-HTTPS</a> (DoH), the idea of fetching DNS results over HTTP is mainstream.<a href="#notes" rel="noreferrer">[2]</a> You still need a DoH-to-text gateway<a href="#notes" rel="noreferrer">[3]</a> for the results to be read as a webhook response, but Google and CloudFlare both offer free, unlimited-use gateways.</p><p>So set up a simple webhook:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/12/image.png" class="kg-image" alt="" loading="lazy" width="1045" height="1395" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/12/image.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2024/12/image.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/12/image.png 1045w" sizes="(min-width: 720px) 720px"></figure><p>Here’s that <em>URL</em> value for copying and pasting:</p><pre><code class="language-markup">https://dns.google/resolve?name={{lead.Email Domain}}&amp;type=mx</code></pre><p>In <em>Response Mappings</em> we’re taking advantage of a Marketo quirk. The <code>Answer</code> property is a <em>JSON array </em>like so:</p><pre><code class="language-javascript">[
  {
    "name" : "cornell.edu.", 
    "type" : 15, 
    "TTL" : 427, 
    "data" : "0 cornell-edu.mail.protection.outlook.com."
  }
]</code></pre><p>When we map the entire <code>Answer</code> to a  string field, Marketo converts the array to a List of Java Maps and then <em>serializes </em>that list to a reasonable (if imperfect) string format:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/12/image-4.png" class="kg-image" alt="" loading="lazy" width="1144" height="812" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/12/image-4.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2024/12/image-4.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/12/image-4.png 1144w" sizes="(min-width: 720px) 720px"></figure><p>For readability, that <em>New Value </em>is:</p><pre><code class="language-markup">List(Map(name -&gt; cornell.edu., type -&gt; 15, TTL -&gt; 427, data -&gt; 0 cornell-edu.mail.protection.outlook.com.))</code></pre><p>The format wouldn’t be suitable for <em>deserialization, </em>but for simple text matching it’s fine.</p><h2 id="one-more-thing-consolidate-lookup-exceptions-into-a-single-error-value">One more thing: Consolidate lookup exceptions into a single <code>ERROR</code> value</h2><p>A few different DNS responses amount to “no MX record.” For example, if a domain has no nameservers at all, they <em>implicitly </em>have no MX records, though the response details are different. I use this Smart Campaign to normalize any response that doesn’t contain Type 15 records to the value <code>ERROR: Domain not found in DNS or no MX record</code>:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/12/image-6.png" class="kg-image" alt="" loading="lazy" width="1109" height="817" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/12/image-6.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2024/12/image-6.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/12/image-6.png 1109w" sizes="(min-width: 720px) 720px"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/12/2024-12-23-14_53_59-Transform-Negative-MX-Queries---Marketing-Activities-and-23-more-pages---Persona.png" class="kg-image" alt="" loading="lazy" width="1101" height="453" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/12/2024-12-23-14_53_59-Transform-Negative-MX-Queries---Marketing-Activities-and-23-more-pages---Persona.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2024/12/2024-12-23-14_53_59-Transform-Negative-MX-Queries---Marketing-Activities-and-23-more-pages---Persona.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/12/2024-12-23-14_53_59-Transform-Negative-MX-Queries---Marketing-Activities-and-23-more-pages---Persona.png 1101w" sizes="(min-width: 720px) 720px"></figure><p>If you need the raw response you can consult the Activity Log for <em>Call Webhook.</em></p><h2 id="or-use-flowboost">Or use FlowBoost</h2><p>With FlowBoost, you can parse out the domain and get the MX in a single webhook call:</p><pre><code class="language-javascript">let emailAddress = {{lead.Email Address}};
let emailParts = FBUtil.string.partsFromEmail(emailAddress);
let emailDomain = emailParts.domain;

FBDns.getMX(emailDomain)
.catch( () =&gt; [] )
.then( mxSet =&gt; {
  success({
    Question: emailDomain,
    Answer: mxSet.map( rr =&gt; rr.exchange )
  })
});
</code></pre><p>But I liked the challenge of doing this with only publicly available services.</p><h5 id="notes">Notes</h5>
<p>[1] Yes, you can also receive email <a href="https://www.rfc-editor.org/rfc/rfc5321.html?ref=blog.teknkl.com#section-5.1">with only a DNS A record</a>, and SMTP-senders still respect that fallback. But in the modern era, if someone doesn’t publish an MX record it’s 99.99999% likely (and that’s generous) they don’t have a functioning mailserver on <code>example.com</code>’s IP, either.</p>
<p>[2] Commercial DNS-to-HTTP services have been around for a long time. They’re just DNS clients that parse the response into JSON, about as simple as it gets. But they’re comically expensive — just look at the prices below. Ridiculous!</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/12/image-5.png" class="kg-image" alt="" loading="lazy" width="640" height="325" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/12/image-5.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/12/image-5.png 640w"></figure><p>[3] DoH uses the binary DNS wire protocol, not JSON (i.e. the response is a binary stream, like when you download an image). So technically, <em>you’re</em> not sending a DoH request from Marketo; rather, <em>Google </em>is using DoH and transforming the response to be webhook-compatible.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Alert when your FlowBoost API key runs out of monthly calls ]]></title>
        <description><![CDATA[ Know when it’s time to upgrade. ]]></description>
        <link>https://blog.teknkl.com/alert-when-your-flowboost-api-key-runs-out-of-monthly-calls/</link>
        <guid isPermaLink="false">6787f3e9a994020001d911b0</guid>
        <category><![CDATA[ FlowBoost ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Sun, 29 Dec 2024 16:03:00 -0500</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/01/flowboostjs_new.png" medium="image"/>
        <content:encoded><![CDATA[ <p>A FlowBoost Community key allows 50,000 calls per month, free forever. How do you know when it’s time to upgrade to Standard? Just use Marketo’s own <em>Send Alert </em>feature!</p><p>When you go over the limit, the webhook call returns an HTTP 429 and <code>Limit Exceeded</code>:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/01/image-12.png" class="kg-image" alt="" loading="lazy" width="1434" height="927" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/01/image-12.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/01/image-12.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/01/image-12.png 1434w" sizes="(min-width: 720px) 720px"></figure><p>You can detect that situation via <em>Webhook is Called:</em></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/01/image-11.png" class="kg-image" alt="" loading="lazy" width="1062" height="318" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2025/01/image-11.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2025/01/image-11.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2025/01/image-11.png 1062w" sizes="(min-width: 720px) 720px"></figure><p>Then <em>Send Alert </em>in the flow. (You’ll get alerted for every call once you’re over the limit, but hey, that keeps the pressure on.)</p><p>P.S. To request an upgrade, send an email to <code>support@teknkl.com</code>. Moving to Standard is near-instantaneous: you just get a key that starts with <code>std:</code> instead of <code>com:</code> and an invoice.😀 </p><p>And remember, <a href="https://blog.teknkl.com/flowboost-usage-stats-fbself-usage/" rel="noreferrer"><code>FBSelf.usage</code></a> returns your remaining calls for the month. </p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Redirector page, redux ]]></title>
        <description><![CDATA[ Linking directly to static assets is a no-go for tracking. A Redirector LP is the way, and the redirector code is easier than when I first wrote it up. ]]></description>
        <link>https://blog.teknkl.com/marketo-redirector-page-redux/</link>
        <guid isPermaLink="false">672c1ae3bd3eb80001105d5f</guid>
        <category><![CDATA[ Munchkin ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Wed, 18 Dec 2024 14:35:00 -0500</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/12/munchkin.png" medium="image"/>
        <content:encoded><![CDATA[ <p>A long way back, you learned that <mark>direct links to static assets (like, say, a PDF) won’t be tracked by Munchkin</mark>. You should instead have a central <a href="https://blog.teknkl.com/stop-using-direct-download-links-unless-you-like-losing-tracking/" rel="noreferrer"><em>Redirector LP</em></a><em> </em>that hooks up the Munchkin cookie before flipping to the target asset.</p><p>Later, I showed how to instrument Munchkin so an <a href="https://blog.teknkl.com/adding-hitcallback-munchkin-hitevent-for-redirect-and-more/" rel="noreferrer">event fires as soon as <em>Visit Web Page </em>has been logged</a>, allowing the fastest response for Munchkin-dependent stuff — like redirecting.</p><p>Hadn’t yet wrapped together those 2 concepts, though. So here’s how you should set up the Redirector LP as of late 2024.</p><p>First, download the Munchkin HitEvent JS (same code introduced <a href="https://blog.teknkl.com/adding-hitcallback-munchkin-hitevent-for-redirect-and-more/" rel="noreferrer">here</a>) from my CDN and re-upload it to your Design Studio:</p><div class="kg-card kg-file-card"><a class="kg-file-card-container" href="https://blog.teknkl.com/content/files/2026/02/teknkl-munchkinhitevent-v1.js" title="Download" download=""><div class="kg-file-card-contents"><div class="kg-file-card-title">teknkl-munchkinhitevent-v1</div><div class="kg-file-card-caption">JS shim to enable triggering on Marketo Munchkin hits</div><div class="kg-file-card-metadata"><div class="kg-file-card-filename">teknkl-munchkinhitevent-v1.js</div><div class="kg-file-card-filesize">5 KB</div></div></div><div class="kg-file-card-icon"><svg viewBox="0 0 24 24"><defs><style>.a{fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.5px;}</style></defs><title>download-circle</title><polyline class="a" points="8.25 14.25 12 18 15.75 14.25"></polyline><line class="a" x1="12" y1="6.75" x2="12" y2="18"></line><circle class="a" cx="12" cy="12" r="11.25"></circle></svg></div></a></div><p>Load that file <mark>in the <code>&lt;head&gt;</code> of your Redirector LP</mark>:</p><pre><code class="language-markup">&lt;!-- Munchkin event enabler --&gt;
&lt;script src="https://pages.example.com/rs/123-ABC-456/teknkl-munchkinhitevent-v1.js"&gt;&lt;/script&gt;
&lt;!-- /Munchkin event enabler --&gt;</code></pre><p>It’s critical that it load before the Munchkin library, which gets automatically injected at the end of the <code>&lt;/body&gt;</code>, so the safe way is to put it in <code>&lt;head&gt;</code>.</p><p>Then use this JS instead of the original redirector JS:</p><pre><code class="language-javascript">redirect: {
   const allowedOrigins = [ // which domains are allowed for redirection
      "https://pages.example.com",
      "https://www.example.com",
      "https://example.com"
   ]; 
         
   const errNoAsset = "Asset URL not found."; // message when no asset in hash
   const errInvalidAsset = "Asset URL not allowed."; // when asset not our domain
   const redirectMs = 2000;
   
   let assetURL;
   try {
     assetURL = new URL(decodeURIComponent(document.location.hash.substring(1)));
   } catch(e){
     document.body.append(errNoAsset);
     break redirect;      
   }
      
   if(!allowedOrigins.includes(assetURL.origin)){
     document.body.append(errInvalidAsset);
     break redirect;
   }
   
   enableMunchkinHitEvent({ forceAfter: redirectMs, debug: true });
   
   document.addEventListener("munchkin.visitWebPage", function(e){
     document.location.href = assetURL.href;
   });
}</code></pre><p>The JS is less clunky now as browsers do a lot more stuff natively than they did back in 2017 (!!).</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Hooking Conversion Path into HubSpot forms is crazy easy ]]></title>
        <description><![CDATA[ Conversion Path is a solid piece of attribution JS with almost too many features. Good to see a day-one feature seeing some use. ]]></description>
        <link>https://blog.teknkl.com/hooking-conversion-path-into-hubspot-forms-is-crazy-easy/</link>
        <guid isPermaLink="false">670d4d8cb398dd00017862a9</guid>
        <category><![CDATA[ conversion path ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Wed, 18 Dec 2024 14:30:55 -0500</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/12/misc_form_builders_and_widgets.png" medium="image"/>
        <content:encoded><![CDATA[ <p>A long time ago, my friend Joe Fusaro (now at <a href="https://datachef.com/?ref=blog.teknkl.com" rel="noreferrer">DataChef</a>) and I wrote the still-solid <a href="https://docs.google.com/document/d/1FVYMySHF41NVaSiHyS_4geZbJ2UKH6G1Ybl_8dovtkE/edit?ref=blog.teknkl.com#heading=h.12xaznxjjvug" rel="noreferrer">Conversion Path</a> JS attribution library. Conversion Path takes the usually lightweight “UTM-to-cookie” concept and injects it with some kind of superdrug. It has an insane amount of configurability.</p><p>When we wrote it, we were adamant about our JS API hooking into <em>any </em>form, including a plain vanilla HTML <code>&lt;form&gt;</code>. (It integrates with Marketo forms automatically.)</p><p>Few — or maybe zero — users explored Conversion Path <em>Custom Events</em> back in the day, but recently someone switched to Hubspot and wanted to stick with the script. And it was mind-blowingly simple, thanks to the work put in back then:</p><pre><code class="language-javascript">&lt;script type="application/json" id="CP_TouchHistoryOptions"&gt;
{
  "events": {
    "model" : "customEvents",
    "injectHiddenFields" : false
  }
}
&lt;/script&gt;
&lt;script src="https://du4pg90j806ok.cloudfront.net/js/touch-history/dist/conversionpath-0.4.3.min.js"&gt;&lt;/script&gt;
&lt;script&gt;
  window.addEventListener("message", function(e){
    if( e.origin === document.location.origin &amp;&amp; e.data.type === "hsFormCallback" ) {
      let formEl = document.querySelector(`form.hsForm_${e.data.id}`);

      if( e.data.eventName === "onFormReady" ) {
        let ce = new Event("RC_TouchHistoryFormReady", { bubbles: true });      
        formEl.dispatchEvent(ce);
      } else if ( e.data.eventName === "onBeforeFormSubmit" ) {
        let ce = new Event("RC_TouchHistoryRecordable", { bubbles: true });
        formEl.dispatchEvent(ce);
      }
    }
  });
&lt;/script&gt;</code></pre><p>Enjoy!</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Triggering the first time you see a lead domain using FlowBoost ]]></title>
        <description><![CDATA[ Bet you didn’t know about this. Also bet you know whose fault that is. ]]></description>
        <link>https://blog.teknkl.com/trigger-on-net-new-email-domain-flowboost/</link>
        <guid isPermaLink="false">65a72dd5c7e99a0001797b4f</guid>
        <category><![CDATA[ FlowBoost ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Fri, 13 Dec 2024 15:37:00 -0500</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/01/flowboostjs_new.png" medium="image"/>
        <content:encoded><![CDATA[ <p>There are <a href="https://blog.teknkl.com/achieving-true-round-robin-not-random-lead-assignment-with-flowboost//" rel="noreferrer">so</a> <a href="https://blog.teknkl.com/count-form-fills-last-hour-flowboost/" rel="noreferrer">many</a> <a href="https://blog.teknkl.com/smtp-rate-limit-fixed-buckets-flowboost/" rel="noreferrer">cool things</a> you can do with FlowBoost’s FBCounter that go beyond the obvious case of counting program members/event registrants.</p><p>(As always, <em>mea culpa </em>because the rather meager documentation of FBCounter doesn’t mention these. They mostly come up when a user responds to the onboarding email with a question and I go, “Of course!” ☺)</p><p>When you call <code>FBCounter.add(counterName, value)</code> the response has a different shape if the <code>counterName</code> already existed vs. if it’s brand new.</p><p>If it’s brand new, it looks like this:</p><pre><code class="language-javascript">{
  "response": {
    "body": {
      "action": "set",
      "node": {
        "key": "/pub:omuryeoqwuk5vqpw6zpaxtdr/knownDomains/example.com",
        "value": "248832",
        "modifiedIndex": 4453898,
        "createdIndex": 4453898
      }
    }
}</code></pre><p>While if the <code>counterName</code> already existed, it looks like this — note the <code>prevNode</code> (“previous node”) object:</p><pre><code class="language-javascript">{
  "response": {
    "body": {
      "action": "set",
      "node": {
        "key": "/pub:omuryeoqwuk5vqpw6zpaxtdr/knownDomains/example.com",
        "value": "248836",
        "modifiedIndex": 4453902,
        "createdIndex": 4453902
      },
      "prevNode": {
        "key": "/pub:omuryeoqwuk5vqpw6zpaxtdr/knownDomains/example.com",
        "value": "248832",
        "modifiedIndex": 4453901,
        "createdIndex": 4453901
      }
    }
  }
}</code></pre><p>Therefore, you can use the presence of <code>prevNode</code> to flag a domain that’s new to your database. Here’s that FlowBoost webhook payload:</p><pre><code class="language-javascript">let emailParts = FBUtil.string.partsFromEmail({{Lead.Email Address}});
let emailDomain = emailParts.domain;

FBCounter.add(`/knownDomains/${emailDomain}`, {{Lead.Id}})
.then( upserted =&gt; {
  success({
    isNetNewDomain : upserted.body.prevNode ? false : true
  });
});
</code></pre><p>And the response, suitable for mapping to a Person field or just scanning in a <em>Webhook is Called </em>trigger:</p><pre><code class="language-javascript">{
  "response" {
    "netNewDomain" : true
}</code></pre><p>Pretty cool, eh? This is one part of a “poor man’s ABM” solution you can build using FlowBoost’s ability to combine data across multiple leads.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Integrations using Marketo’s “Get Folders” API must expect the same Folder ID on multiple pages ]]></title>
        <description><![CDATA[ “Makes sense if you think about it,” he said smugly. ]]></description>
        <link>https://blog.teknkl.com/integrations-using-the-get-folders-api-must-expect-the-same-folder-id-on-mu/</link>
        <guid isPermaLink="false">6748d2524c01e00001366ec3</guid>
        <category><![CDATA[ Marketo REST API ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Wed, 11 Dec 2024 01:54:26 -0500</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/12/marketo_api.png" medium="image"/>
        <content:encoded><![CDATA[ <p>On a recent Marketing Nation thread, a developer had questions about the <a href="https://developer.adobe.com/marketo-apis/api/asset/?ref=blog.teknkl.com#operation/getFolderUsingGET" rel="noreferrer">Get Folders</a> API endpoint, one of Marketo’s <mark>paginated endpoints</mark> that returns folders in batches of (up to) 200:</p><blockquote><em>While paging through&nbsp;/rest/asset/v1/folders.json using the offset parameter, we are seeing duplicate folder ids in later pages that were returned in earlier pages.<br><br>e.g. with a maxReturn=200 and offset=0, we get folder id 130 in the results. Then with the same maxReturn=200 and offset=200, we again get folder 130 in the results.</em></blockquote><p>A brief moment of 😵‍ is understandable, but this behavior makes perfect sense.</p><p>See,<mark> each page is computed upon request</mark>. There’s no pre-computed result set containing <em>all pages </em>as of a point in time. (More on this in the <a href="#notes" rel="noreferrer">notes</a>.) So the underlying database state can easily change while you’re fetching pages.</p><p>Take a folder tree like this:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/11/image-11.png" class="kg-image" alt="" loading="lazy" width="326" height="509"></figure><p>Let’s fetch the root and the first subfolder:</p><pre><code>https://123-ABC-456.mktorest.com/rest/asset/v1/folders.json?root=22523&amp;maxReturn=2&amp;offset=0</code></pre><pre><code class="language-javascript">{
    "result": [
        {
            "name": "Today's Root",
            "description": "",
            "createdAt": "2024-11-28T20:30:17Z+0000",
            "updatedAt": "2024-11-28T20:30:17Z+0000",
            "url": "https://app-us00.marketo.com/#MF5944A1",
            "folderId": {
                "id": 22523,
                "type": "Folder"
            },
            "folderType": "Marketing Folder",
            "parent": {
                "id": 13531,
                "type": "Folder"
            },
            "path": "/Marketing Activities/Default/!Universal/Today's Root",
            "isArchive": false,
            "isSystem": false,
            "accessZoneId": 1,
            "workspace": "Default",
            "id": 22523
        },
        {
            "name": "Folder 1",
            "description": "",
            "createdAt": "2024-11-28T20:30:33Z+0000",
            "updatedAt": "2024-11-28T20:30:33Z+0000",
            "url": "https://app-us00.marketo.com/#MF5945A1",
            "folderId": {
                "id": 22524,
                "type": "Folder"
            },
            "folderType": "Marketing Folder",
            "parent": {
                "id": 22523,
                "type": "Folder"
            },
            "path": "/Marketing Activities/Default/!Universal/Today's Root/Folder 1",
            "isArchive": false,
            "isSystem": false,
            "accessZoneId": 1,
            "workspace": "Default",
            "id": 22524
        }
    ]
}</code></pre><p>Now get the next 2 subfolders:</p><pre><code class="language-javascript">https://123-ABC-456.mktorest.com/rest/asset/v1/folders.json?root=22523&amp;maxReturn=2&amp;offset=2</code></pre><pre><code class="language-javascript">{
    "result": [
        {
            "name": "Folder 2",
            "description": "",
            "createdAt": "2024-11-28T20:30:42Z+0000",
            "updatedAt": "2024-11-28T20:30:42Z+0000",
            "url": "https://app-us00.marketo.com/#MF5946A1",
            "folderId": {
                "id": 22525,
                "type": "Folder"
            },
            "folderType": "Marketing Folder",
            "parent": {
                "id": 22523,
                "type": "Folder"
            },
            "path": "/Marketing Activities/Default/!Universal/Today's Root/Folder 2",
            "isArchive": false,
            "isSystem": false,
            "accessZoneId": 1,
            "workspace": "Default",
            "id": 22525
        },
        {
            "name": "Folder 3",
            "description": "",
            "createdAt": "2024-11-28T20:30:51Z+0000",
            "updatedAt": "2024-11-28T20:30:51Z+0000",
            "url": "https://app-us00.marketo.com/#MF5947A1",
            "folderId": {
                "id": 22526,
                "type": "Folder"
            },
            "folderType": "Marketing Folder",
            "parent": {
                "id": 22523,
                "type": "Folder"
            },
            "path": "/Marketing Activities/Default/!Universal/Today's Root/Folder 3",
            "isArchive": false,
            "isSystem": false,
            "accessZoneId": 1,
            "workspace": "Default",
            "id": 22526
        }
    ]
}</code></pre><p>Now someone creates a new subfolder, <em>Folder 2.5, </em>while we’re running our job:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/11/image-10.png" class="kg-image" alt="" loading="lazy" width="361" height="584"></figure><p>And when we get the next 2 subfolders from our last offset, we see <em>Folder 3 </em>again!</p><pre><code>https://123-ABC-456.mktorest.com/rest/asset/v1/folders.json?root=22523&amp;maxReturn=2&amp;offset=4</code></pre><pre><code class="language-javascript">{
    "result": [
        {
            "name": "Folder 3",
            "description": "",
            "createdAt": "2024-11-28T20:30:51Z+0000",
            "updatedAt": "2024-11-28T20:30:51Z+0000",
            "url": "https://app-us00.marketo.com/#MF5947A1",
            "folderId": {
                "id": 22526,
                "type": "Folder"
            },
            "folderType": "Marketing Folder",
            "parent": {
                "id": 22523,
                "type": "Folder"
            },
            "path": "/Marketing Activities/Default/!Universal/Today's Root/Folder 3",
            "isArchive": false,
            "isSystem": false,
            "accessZoneId": 1,
            "workspace": "Default",
            "id": 22526
        },
        {
            "name": "Folder 4",
            "description": "",
            "createdAt": "2024-11-28T20:31:02Z+0000",
            "updatedAt": "2024-11-28T20:31:02Z+0000",
            "url": "https://app-us00.marketo.com/#MF5948A1",
            "folderId": {
                "id": 22527,
                "type": "Folder"
            },
            "folderType": "Marketing Folder",
            "parent": {
                "id": 22523,
                "type": "Folder"
            },
            "path": "/Marketing Activities/Default/!Universal/Today's Root/Folder 4",
            "isArchive": false,
            "isSystem": false,
            "accessZoneId": 1,
            "workspace": "Default",
            "id": 22527
        }
    ]
}</code></pre><p>Similarly, deletes while a paginated job is running can change the overall result if folders slide backward out of view.</p><p>These are just things you must expect in order to build a resilient Marketo integration. <mark>An ostensibly “full” <em>Get Folders </em>job isn’t guaranteed to include all folder IDs that existed when the job started. Nor is it guaranteed to include a given folder ID only once.</mark></p><h2 id="mitigation-tactics">Mitigation tactics</h2><p>You can mitigate the impact in a few ways:</p><ul><li>sync more frequently: this keeps your users happier either way</li><li>parallelize: use 2 workers to fetch data — staying under the 100 calls/20 secs rate limit, of course — to speed up the whole job</li><li>re-crawl from programs: periodically run <em>Get Programs </em>using a recent date range filter, then crawl backwards from each program to get its ancestor folders (not exactly the same as getting recent folders, but fills many gaps)</li><li>acknowledge the possibility in your product docs, but don’t worry about it (far better than nothing!)</li></ul><h5 id="notes">Notes</h5>
<p><em>Get Folders</em> uses a basic paginated model akin to <code>OFFSET</code>/<code>LIMIT</code> in SQL. Because it’s stateless, your app doesn’t need to know anything about the next request other than the offset (and in fact that statelessness allows for parallelization, within limits). </p><p>In contrast, stateful models such as <a href="https://www.merge.dev/blog/keyset-pagination?ref=blog.teknkl.com" rel="noreferrer">keyset pagination</a> ensure consistency across multi-page jobs, but they require server-generated pointers to each page and are more complex to implement. Marketo uses a similar approach for the Activity API, <a href="https://experienceleague.adobe.com/en/docs/marketo-developer/marketo/rest/paging-tokens?ref=blog.teknkl.com" rel="noreferrer">paging tokens</a>.</p><p>One rarely used model is where the server fetches the full result set in response to the client requesting a single page. Results can even begin streaming while being cached on disk, like a<a href="https://www.gnu.org/software/coreutils/manual/html_node/tee-invocation.html?ref=blog.teknkl.com" rel="noreferrer"><code>tee</code></a>; HTTP <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests?ref=blog.teknkl.com" rel="noreferrer"><code>range</code></a> lets you fetch file chunks without additional overhead. In theory at least, that’s the best of both worlds! Problem is it doesn’t scale: disk storage quickly grows out of control, and you waste resources generating data that may never be used.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ A fully customizable “Forward to Friend” using a standard Marketo form + embedded “View as Web Page” ]]></title>
        <description><![CDATA[ Take the embedded “View as Web Page” and add a form + creative webhook. Now you’ve got a Forward to Friend that might (?) actually see some use! ]]></description>
        <link>https://blog.teknkl.com/completing-the-custom-forward-to-friend-embedded-vawp/</link>
        <guid isPermaLink="false">671b1f524cda5c0001d08036</guid>
        <category><![CDATA[ forward to friend ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Tue, 05 Nov 2024 02:11:26 -0500</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/11/lp_editor.png" medium="image"/>
        <content:encoded><![CDATA[ <p><a href="https://blog.teknkl.com/embed-view-as-web-page-vawp-as-thumbnail-iframe/" rel="noreferrer">My last post</a> showed how to embed “View as Web Page” (VAWP) as a preview on a fully branded “Forward to Friend” (F2F) page, like so:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/10/image-3.png" class="kg-image" alt="" loading="lazy" width="1000" height="749" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/10/image-3.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/10/image-3.png 1000w" sizes="(min-width: 720px) 720px"></figure><p>Now, let’s learn how to turn a standard Marketo form into a F2F form so we don’t have to use the — let’s face it — pretty ugly popup that comes with the usual F2F link. You know, this one:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/11/image-7.png" class="kg-image" alt="" loading="lazy" width="707" height="426" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/11/image-7.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/11/image-7.png 707w"></figure><h2 id="the-f2f-endpoint-is-just-an-alternate-form-listener">The F2F endpoint is just an alternate form listener</h2><p>The F2F form, like any HTML form, posts to a <em>form listener </em>(that is, HTTP service that expects <code>application/x-www-form-urlencoded</code> payloads). It’s <mark>not the same listener used by Forms 2.0 forms</mark>, but even simpler. It just expects a GET request with 4 fields:</p><ul><li><code>fname</code> (first name)</li><li><code>lname</code> (last name)</li><li><code>emailAddress</code> (email address)</li><li><code>mkt_tok</code> (the mkt_tok value that identifies the email to forward and from whom)</li></ul><p><mark>You can hit the F2F listener from a webhook</mark> and it’s exactly the same as if an end user filled out the form. (You probably just had a lightbulb moment!)</p><h2 id="set-up-a-field-for-the-mkttok">Set up a field for the <code>mkt_tok</code></h2><p>The <code>mkt_tok</code> is automatically submitted with Forms 2.0 forms, but that system-level field isn’t available as a token or in Smart Lists.<a href="#notes" rel="noreferrer">[1]</a></p><p>So to expose the <code>mkt_tok</code> in userland, create a custom String field on Person named <em>Last Requested Email mkt_tok:</em></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/11/image-6.png" class="kg-image" alt="" loading="lazy" width="931" height="659" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/11/image-6.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/11/image-6.png 931w" sizes="(min-width: 720px) 720px"></figure><h2 id="set-up-the-webhook">Set up the webhook</h2><p>The webhook couldn’t be simpler. Just substitute your LP domain for <code>info.example.com</code> below and that’s your GET payload:</p><pre><code class="language-markup">https://info.example.com/index.php/email/forwardToFriend?fname={{lead.First Name}}&amp;lname={{lead.Last Name}}&amp;emailAddress={{lead.Email Address}}&amp;mkt_tok={{lead.Last Requested Email mkt_tok}}</code></pre><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/11/image-5.png" class="kg-image" alt="" loading="lazy" width="791" height="956" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/11/image-5.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/11/image-5.png 791w" sizes="(min-width: 720px) 720px"></figure><p>You don’t need any <em>Custom Headers </em>or <em>Response Mappings.</em></p><h2 id="create-a-standard-form">Create a standard form</h2><p>(This step intentionally left mostly blank. Just make a form with any fields you want to collect.)</p><h2 id="enhance-the-page-js">Enhance the page JS</h2><p>The code from the previous post populates the <code>&lt;iframe&gt;</code> with an email preview. Add a little more code so the F2F form includes the new <em>Last Requested Email mkt_tok </em>and <em>doesn’t </em>include the typical cookie and <code>mkt_tok</code> fields (you don’t want to send those for a referral-type form):</p><pre><code class="language-javascript">// same code as in https://blog.teknkl.com/embed-view-as-web-page-vawp-as-thumbnail-iframe/ 
let currentURL = new URL(document.location.href);
let mktTok = currentURL.searchParams.get("mkt_tok") || window.__mktTokVal || sessionStorage.getItem(`${currentURL.pathname}?mkt_tok`);
sessionStorage.setItem(`${currentURL.pathname}?mkt_tok`, mktTok);
        
let emailWebView = document.querySelector("#emailWebView");
let iframeURL = new URL("/index.php/email/emailWebview", currentURL.origin);
iframeURL.searchParams.set("mkt_tok", mktTok);
fetch(iframeURL)
  .then( resp =&gt; resp.text() )
  .then( body =&gt; emailWebView.srcdoc = Document.parseHTMLUnsafe(body).documentElement.outerHTML );

// new code to add the mkt_tok to the F2F form
MktoForms2.whenReady(function(readyForm){
  readyForm.setValues({
    lastRequestedEmailmkttok: mktTok
  });

  readyForm.addHiddenFields({ 
    _mkt_trk: "",
    mkt_tok: ""
  });
   
  readyForm.onSubmit(function (form) {
    readyForm.setValues({ 
       _mkt_trk: "",
       mkt_tok: ""
    });
  });
});</code></pre><h2 id="create-a-smart-campaign-to-catch-form-fills">Create a Smart Campaign to catch form fills</h2><p>Straightforward form catcher campaign that calls the ’hook:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/11/image-1.png" class="kg-image" alt="" loading="lazy" width="1371" height="442" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/11/image-1.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2024/11/image-1.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/11/image-1.png 1371w" sizes="(min-width: 720px) 720px"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/11/image.png" class="kg-image" alt="" loading="lazy" width="1378" height="450" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/11/image.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2024/11/image.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/11/image.png 1378w" sizes="(min-width: 720px) 720px"></figure><h2 id="create-a-smart-campaign-to-preemptively-unsubscribe-%E2%80%9Cfriends%E2%80%9D">Create a Smart Campaign to preemptively unsubscribe “friends”</h2><p>The F2F listener does one interesting thing beyond resending an email. It also <mark>sets <em>Unsubscribed </em>to <code>true</code> for net new people</mark>. The idea being a person can be referred and get an initial email, but to market to them later you need to get them to fill out a form and officially subscribe. </p><p>So let’s duplicate that feature by flagging people who were <em>created </em>via the Forms 2.0 form:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/11/image-2.png" class="kg-image" alt="" loading="lazy" width="1375" height="685" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/11/image-2.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2024/11/image-2.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/11/image-2.png 1375w" sizes="(min-width: 720px) 720px"></figure><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/11/image-4.png" class="kg-image" alt="" loading="lazy" width="1376" height="881" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/11/image-4.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2024/11/image-4.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/11/image-4.png 1376w" sizes="(min-width: 720px) 720px"></figure><p>N.B. Latency between the F2F service and the core platform is unpredictable enough that I’m forced to recommend an arbitrary <em>Wait </em>step. I hate doing it, but we don’t have an event to trigger on. 15 minutes will work fine: if it takes longer than that for Marketo to queue the message, you’ve got far bigger problems!</p><h2 id="detect-errors-if-you%E2%80%99re-feeling-thorough">Detect errors, if you’re feeling thorough</h2><p>To detect errors calling the F2F listener (which shouldn’t happen, but hey) trigger on webhook responses missing the string <code>Successfully Sent</code>:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/11/image-3.png" class="kg-image" alt="" loading="lazy" width="1376" height="490" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/11/image-3.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2024/11/image-3.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/11/image-3.png 1376w" sizes="(min-width: 720px) 720px"></figure><h2 id="alternate-approach">Alternate approach</h2><p>I know what some of you are thinking and yes: you <em>could </em>call the F2F listener on the client side, i.e. in a custom <code>onSuccess</code> function, instead of using the webhook. Or even call the F2F first via some tricky <code>onSubmit</code> stuff. </p><p>But I’m wary of a possible race condition between the Forms 2.0 POST and the F2F GET when they occur &lt; 1s apart and prefer to know the exact order (real form post, then F2F ’hook).</p><h5 id="notes">Notes</h5>
<p>[1] Also, for F2F purposes, we don’t want to mix up the person who received the original email (the referr<em>er</em>) with the F2F recipient (the referr<em>al</em>).</p>
 ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Embedding “View as Web Page” as a thumbnail-like preview on a standard Marketo LP ]]></title>
        <description><![CDATA[ If someone is engaged enough to click “View as Web Page,” might as well give them more to see and do. ]]></description>
        <link>https://blog.teknkl.com/embed-view-as-web-page-vawp-as-thumbnail-iframe/</link>
        <guid isPermaLink="false">670b3f0acf62870001201556</guid>
        <category><![CDATA[ view as web page ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Mon, 21 Oct 2024 14:23:11 -0400</pubDate>
        <media:content url="" medium="image"/>
        <content:encoded><![CDATA[ <p>We’re building out an <em>account-based referral program </em>for a client. The program lets people invite colleagues to in-person events. It’s like “Forward to Friend” on PEDs.</p><p>An email preview needs to accompany the referral form, and rather than the huge time suck of manually creating thumbnail images, we realized the standard “View as Web Page” (VAWP) would be perfect if we could get it into an <code>&lt;iframe&gt;</code>:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/10/Untitled--2-.png" class="kg-image" alt="" loading="lazy" width="2000" height="1498" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/10/Untitled--2-.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2024/10/Untitled--2-.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1600/2024/10/Untitled--2-.png 1600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w2400/2024/10/Untitled--2-.png 2400w" sizes="(min-width: 720px) 720px"></figure><p>Turned out to be net-easy — though, as with many such projects, 10% harder than you might think!</p><h2 id="set-up-the-html-and-css">Set up the HTML and CSS </h2><p>First, the <code>&lt;iframe&gt;</code> and some critical styles and attributes:</p><pre><code class="language-markup">&lt;style&gt;
iframe#emailWebView {
  pointer-events: none;
  user-select: none;
}
&lt;/style&gt;
&lt;iframe id="emailWebView" scrolling="no" tabindex="-1"&gt;&lt;/iframe&gt;</code></pre><p>The HTML attributes <code>scrolling</code> and <code>tabindex</code>, together with those two CSS “event neutralizing” styles, make the <code>&lt;iframe&gt;</code> behave like a static image so it can’t be accidentally clicked or selected.<a href="#notes" rel="noreferrer">*</a></p><p>Set <code>width</code> and <code>height</code> and other styles to fit your page. In the screenshot I even added <code>filter: grayscale(1)</code> because I thought it looked cool.😊</p><h2 id="populate-the-iframe">Populate the <code>&lt;iframe&gt;</code> </h2><p>Here’s where that “10% harder” came in. </p><p>You might think you could just set the <code>&lt;iframe src&gt;</code> to the VAWP URL (<code>https://landingpages.example.com/index.php/email/emailWebview</code>) with the main document’s <code>mkt_tok</code> copied into its query string, e.g.:</p><pre><code class="language-javascript">    let currentURL = new URL(document.location.href);
    let mktTok = currentURL.searchParams.get("mkt_tok");

    let emailWebView = document.querySelector("#emailWebView");
    let iframeURL = new URL("/index.php/email/emailWebview", currentURL.origin);
    iframeURL.searchParams.set("mkt_tok", mktTok);

    emailWebView.src = iframeURL;</code></pre><p><strong>But this won’t work.</strong> Why? Because Marketo sends the HTTP header <code>X-Frame-Options: DENY</code> when you request the VAWP page <mark>even if you think you’ve turned that off in Admin</mark>. </p><p>That is, this valuable setting.. </p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/10/image-2.png" class="kg-image" alt="" loading="lazy" width="790" height="136" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/10/image-2.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/10/image-2.png 790w" sizes="(min-width: 720px) 720px"></figure><p>... works for regular LPs under <code>https://landingpages.example.com</code>, but <mark>the VAWP service doesn’t pay attention and keeps sending <code>DENY</code>.</mark> This is a longstanding <s>bug</s> oddity. Luckily, it’s easy to work around.</p><h2 id="%E2%80%99stead-of-src-fetch-into-srcdoc">’stead of <code>src</code>, fetch into <code>srcdoc</code></h2><p>Obvious-when-you-think-about-it fact about <code>X-Frame-Options</code> is it can’t stop you from <mark>setting an </mark><a href="https://developer.mozilla.org/en-US/docs/Web/API/HTMLIFrameElement/srcdoc?ref=blog.teknkl.com" rel="noreferrer"><code><mark>&lt;iframe srcdoc&gt;</mark></code></a><mark> (not <code>src</code> but <code>srcdoc</code>) to whatever HTML you want</mark>. The HTML might’ve been fetched as-is from a same-origin URL or even a CORS URL, but <code>srcdoc</code> has no idea where it originally came from.</p><p>And unless <em>very rare </em>measures (like, “never seen in real life” type of rare) are taken, you can <code>fetch</code> or <code>XMLHttpRequest</code> any public same-origin resource.</p><p>So we just need to GET the VAWP URL and then set the <code>srcdoc</code> to the raw response:</p><pre><code class="language-javascript">    let currentURL = new URL(document.location.href);
    let mktTok = currentURL.searchParams.get("mkt_tok");
        
    let emailWebView = document.querySelector("#emailWebView");
    let iframeURL = new URL("/index.php/email/emailWebview", currentURL.origin);
    iframeURL.searchParams.set("mkt_tok", mktTok);
    fetch(iframeURL)
      .then( resp =&gt; resp.text() )
      .then( body =&gt; emailWebView.srcdoc = body );</code></pre><p>Presto!</p><h2 id="adding-resilience">Adding resilience</h2><p>The code above works fine, but it’s a<em> </em>bit fragile. More resilient code is below.</p><p>One fragile part: if you inject that code via a tag manager (<em>don’t</em> if you can avoid it, since it just hurts performance!) there’s a race condition with the built-in JS that moves the <code>mkt_tok</code> query param into the <code>__mktTokVal</code> global variable. So better to look for the token in both places.</p><p>Another: if the end user refreshes the page, there won’t be a <code>mkt_tok</code> and the VAWP will respond with <strong><em>Cannot get email content</em> </strong>(same way it always works). Persisting the <code>mkt_tok</code> in Session Storage takes care of that.</p><p>Finally: why not create a real live document (as in <a href="https://developer.mozilla.org/en-US/docs/Web/API/Document?ref=blog.teknkl.com" rel="noreferrer">Document object</a>) from the fetched HTML and then set the <code>srcdoc</code> from that doc? Helps you think about other modifications you might make to the HTML before populating the <code>&lt;iframe&gt;</code>.</p><p>So let’s use this:</p><pre><code class="language-javascript">    let currentURL = new URL(document.location.href);
    let mktTok = currentURL.searchParams.get("mkt_tok") || window.__mktTokVal || sessionStorage.getItem(`${currentURL.pathname}?mkt_tok`);
    sessionStorage.setItem(`${currentURL.pathname}?mkt_tok`, mktTok);
        
    let emailWebView = document.querySelector("#emailWebView");
    let iframeURL = new URL("/index.php/email/emailWebview", currentURL.origin);
    iframeURL.searchParams.set("mkt_tok", mktTok);
    fetch(iframeURL)
      .then( resp =&gt; resp.text() )
      .then( body =&gt; emailWebView.srcdoc = Document.parseHTMLUnsafe(body).documentElement.outerHTML );</code></pre><h5 id="notes">Notes</h5>
<p>* You could also <code>sandbox</code> it but that disables some cool stuff I’ll show in a future post.</p>
 ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Sending reminders from Marketo when a date falls in the next month ]]></title>
        <description><![CDATA[ Getting the audience for “Your subscription expires next month” is easy, but not quite built-in. ]]></description>
        <link>https://blog.teknkl.com/reminder-due-date-like-in-time-frame-next-month/</link>
        <guid isPermaLink="false">66ae902f21e1e70001aba0cf</guid>
        <category><![CDATA[ Smart Campaigns ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Fri, 06 Sep 2024 01:39:18 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/08/logos_v2.fw.png" medium="image"/>
        <content:encoded><![CDATA[ <p>You might’ve just groaned, “A whole post about the <em>[in time frame] = Next Month </em>filter? Ugh.”</p><p>Hey, give me a little credit. You might think you merely haven’t used it, but the reality is <em><mark>there is no such [in time frame] option!</mark> </em>This is all we’ve got:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/08/image-3.png" class="kg-image" alt="" loading="lazy" width="1028" height="566" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/08/image-3.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2024/08/image-3.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/08/image-3.png 1028w" sizes="(min-width: 720px) 720px"></figure><p>Luckily, it’s easy to build work-alike logic.</p><p>Assume your current field is called <em>Expiration Date. </em>Create a new Date field named <em>Next Reminder Date.</em></p><p>Keep that new field updated using a daily batch running after midnight. Catch changes to <em>Expiration Date </em>as well as new people: </p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/09/image-10.png" class="kg-image" alt="" loading="lazy" width="1266" height="589" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/09/image-10.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2024/09/image-10.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/09/image-10.png 1266w" sizes="(min-width: 720px) 720px"></figure><p>And set the <em>Next Reminder Date </em>back 1 month from <em>Expiration Date:</em></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/09/image-14.png" class="kg-image" alt="" loading="lazy" width="1532" height="231" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/09/image-14.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2024/09/image-14.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/09/image-14.png 1532w" sizes="(min-width: 720px) 720px"></figure><p>Now, using the built-in <em>[in time frame] = This Month </em>on the new field gives you the desired outcome:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/09/image-13.png" class="kg-image" alt="" loading="lazy" width="1232" height="252" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/09/image-13.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2024/09/image-13.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/09/image-13.png 1232w" sizes="(min-width: 720px) 720px"></figure><p>That’s it!</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Sorry, ▒▒▒▒▒▒, but {{system.tokens}} don’t work in webhook response mappings ]]></title>
        <description><![CDATA[ Sounded awesome when we saw it in someone’s setup guide, but they must’ve been fooled by insufficient testing. ]]></description>
        <link>https://blog.teknkl.com/no-you-cant-use-system-tokens-in-webhook-response-mappings/</link>
        <guid isPermaLink="false">66d63c967ea79f00015f270a</guid>
        <category><![CDATA[ webhooks ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Wed, 04 Sep 2024 01:15:45 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/09/webhook.png" medium="image"/>
        <content:encoded><![CDATA[ <p>On our last MUG Q&amp;A call, a Marketo vet asked about a webhook setup recommended by a certain vendor (who will remain anonymous to save embarrassment).    </p><p>The vendor’s setup guide included this striking note:</p><blockquote><strong><em>For items like date, system tokens can be used since the API does not, in most cases, include a timestamp in its response.</em></strong></blockquote><p>And an accompanying screenshot:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/09/image.png" class="kg-image" alt="" loading="lazy" width="610" height="464" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/09/image.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/09/image.png 610w"></figure><p>This was mind-blowing for everyone on the call. Despite being — I think it’s safe to say! — a Marketo webhooks guru, I’d never seen this attempted before. We extended the Q&amp;A by a half hour just to test it out live.</p><p>Problem is <mark>it doesn’t actually work</mark>. The response mapping does do <em>something </em>to the person record<em>, </em>and if you’re not paying close attention (and/or are not an experienced Marketo user, as was probably the case at the vendor) you might <em>think</em> it works.</p><h2 id="setting-up-a-test">Setting up a test</h2><p>I set up a webhook hitting one of my test servers, which always returns this static JSON:</p><pre><code class="language-javascript   ">{
   "car_count" : 123456,
   "item_count" : 117733,
   "item_types" : [
      {
         "item_type" : "Blue Car",
         "item_type_count" : 1115,
         "item_type_ratio" : 12
      },
      {
         "item_type" : "Red Car",
         "item_type_count" : 1225,
         "item_type_ratio" : 14
      },
      {
         "item_type" : "Yellow Car",
         "item_type_count" : 1345,
         "item_type_ratio" : 6
      }
   ],
   "item_types_ratio" : 22
}</code></pre><p>And mapped both a real response field <code>car_count</code> and the built-in token <code>{{system.datetime}}</code>: </p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/09/image-1.png" class="kg-image" alt="" loading="lazy" width="911" height="690" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/09/image-1.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/09/image-1.png 911w" sizes="(min-width: 720px) 720px"></figure><p>Then ran a list of about 15,000 people through the ‘hook (wasn’t messing around with a spot check!). And saw a promising number of people with the same value: </p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/09/image-4.png" class="kg-image" alt="" loading="lazy" width="724" height="1071" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/09/image-4.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/09/image-4.png 724w" sizes="(min-width: 720px) 720px"></figure><p>But therein lay the first clue. It wasn’t just a bunch of people with the same value. It was <em>all 15,000 </em>with the same value. Which meant <mark>it couldn’t possibly be stamped when the webhook returns</mark>.</p><p>See, Marketo’s webhook capacity is very high — which is awesome — but it’s not <em>so </em>high that 15,000 people can start + finish in the same minute. It takes at least a few minutes even with a lightning-fast service on the other end.</p><p>Indeed, you can see that some people fired the webhook at 26 after the hour but their value was set to 25 after: </p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/09/image-9.png" class="kg-image" alt="" loading="lazy" width="1583" height="63" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/09/image-9.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2024/09/image-9.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/09/image-9.png 1583w" sizes="(min-width: 720px) 720px"></figure><p>Then another “Wait, hold on” moment: the webhook <mark>fired on September 2nd, but the value was set to September 1st!</mark> That ain’t right.</p><p>And yet another moment: the webhook fired at 5:26pm US Eastern time but appeared to set the time to 12:25pm US Eastern. That ain’t right, either.</p><p>Making matters weirder, per the Activity Log the value being set was <code>2024-09-01 11:25:04</code>, not <code>2024-09-01 12:25:04</code>:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/09/image-3.png" class="kg-image" alt="" loading="lazy" width="1415" height="905" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/09/image-3.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2024/09/image-3.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/09/image-3.png 1415w" sizes="(min-width: 720px) 720px"></figure><p>This isolated part makes sense, at least. When you set a value to a date-like string in local time, the time zone is assumed to be the server time zone. <mark>In this case, that’s US Central time — so 12:25pm displayed in US Eastern.</mark> Whew. At least not everything is inexplicable.</p><p>But nothing else made sense. We put the setup through a few more tests and saw seemingly random values being written instead of the actual <code>{{system.datetime}}</code>. Sometimes we saw the value of <em>another </em>field from the JSON payload being written.</p><p>All in all, we confirmed this setup is not supported and only appeared to work for the vendor because they saw <em>some </em>value being written to the DateTime! Also, because they were using a Date instead of a DateTime, that obscured some of the errors.</p><h2 id="keep-using-a-smart-campaign">Keep using a Smart Campaign</h2><p>Ignore the vendor’s over-hopeful, under-tested approach.</p><p>The correct way to store when a webhook executed is to <mark>trigger on <em>Webhook is Called </em>and call <em>Change Data Value </em>to set a DateTime field to <code>{{system.datetime}}</code>.</mark></p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ An “Open Marketo Lead in SFDC” button for the Marketo UI ]]></title>
        <description><![CDATA[ You know you want it: jump to a Marketo person’s synced SFDC Contact or Lead with a click. ]]></description>
        <link>https://blog.teknkl.com/open-in-sfdc-bookmarklet-for-marketo/</link>
        <guid isPermaLink="false">6691a22eca6d8b0001af1634</guid>
        <category><![CDATA[ bookmarklet ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Sun, 04 Aug 2024 02:05:00 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/09/sfdcsync-1.png" medium="image"/>
        <content:encoded><![CDATA[ <p>Who among us <em>hasn’t </em>looked up a Lead or Contact in SFDC while checking the corresponding person in Marketo? Ridiculously common for me, and even if it’s not exactly hard<em>, </em>there’s no one-click way to do it. Until now.</p><p>Just go to the <a href="https://7e603b0070a43c1fc7050a9d16cd6e47.codepen.website/?ref=blog.teknkl.com" rel="noreferrer">SFDC Bookmarklet Maker</a> and enter your SFDC “My Domain” in the top field:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/07/image-7.png" class="kg-image" alt="" loading="lazy" width="357" height="331"></figure><p>The checkbox means each SFDC Lead/Contact opens in a new tab, instead of having a single SFDC tab that gets continually replaced (the default).</p><p>You can optionally change the title of the bookmarklet before dragging it. Click the pencil (🖉) to edit and the check (✓) to save:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/07/image-6.png" class="kg-image" alt="" loading="lazy" width="357" height="331"></figure><p>Then drag the link to your Bookmarks bar:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/07/image-8.png" class="kg-image" alt="" loading="lazy" width="434" height="82"></figure><p>Presto! Now, whenever you’re viewing a record in the Marketo Lead UI, clicking the bookmarklet takes you to the corresponding SFDC record.</p><h2 id="required-sfdc-id-on-your-custom-tab">Required: <em>SFDC ID </em>on your custom tab</h2><p>There is one prerequisite: <mark>the <em>SFDC ID </em>field needs to be <em>somewhere </em>on your Custom tab</mark> (the exact position doesn’t matter). Check that in <em>Admin » Field Management » Custom Layout Designer:</em></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/07/image-9.png" class="kg-image" alt="" loading="lazy" width="1926" height="657" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/07/image-9.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2024/07/image-9.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1600/2024/07/image-9.png 1600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/07/image-9.png 1926w" sizes="(min-width: 720px) 720px"></figure> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Don’t worry about having too many Thank You page options ]]></title>
        <description><![CDATA[ A warning in the Marketo docs proves to be a false alarm. ]]></description>
        <link>https://blog.teknkl.com/no-thank-you-choices-dont-slow-down-forms/</link>
        <guid isPermaLink="false">66ac5ed821e1e70001aba038</guid>
        <category><![CDATA[ Marketo forms ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Fri, 02 Aug 2024 14:19:03 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/09/fud.png" medium="image"/>
        <content:encoded><![CDATA[ <p>There’s a warning in the <a href="https://experienceleague.adobe.com/en/docs/marketo/using/product-docs/demand-generation/forms/creating-a-form/set-a-form-thank-you-page?ref=blog.teknkl.com" rel="noreferrer">forms docs</a> that made an admin skittish the other day: </p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/08/image-1.png" class="kg-image" alt="" loading="lazy" width="1400" height="149" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/08/image-1.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2024/08/image-1.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/08/image-1.png 1400w" sizes="(min-width: 720px) 720px"></figure><p>For want of a euphemism: <mark>this warning is FUD.</mark> The number of choices you add in this dialog <mark>has no effect on form loading speed</mark> at all:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/08/image.png" class="kg-image" alt="" loading="lazy" width="1029" height="819" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/08/image.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2024/08/image.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/08/image.png 1029w" sizes="(min-width: 720px) 720px"></figure><p>While I’m not privy to the edit history, the error is likely due to a misunderstanding. The Thank You choices can — if you have hundreds of them — <em>slow down that particular page in Form Editor</em>. An engineer might’ve mentioned that to a tech writer in passing.</p><p>But the choices are simply not loaded alongside the form. They’re <mark>not part of the public form descriptor</mark> (i.e. response to a <code>/getForm</code> call) and therefore have no effect on how long the form takes to load and render.</p><p>You can see that easily in your browser. Here, for example, are two forms that have <em>one</em> Thank You choice and <em>200</em> Thank You choices, respectively:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/08/image-2.png" class="kg-image" alt="" loading="lazy" width="2000" height="143" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/08/image-2.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2024/08/image-2.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1600/2024/08/image-2.png 1600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/08/image-2.png 2064w" sizes="(min-width: 720px) 720px"></figure><p>They have the same size and transfer time.</p><p>Thank You choices are only consulted upon form submission — <mark>on the server side</mark> —where finding the first match is instantaneous.</p><h2 id="thank-you-choices-are-private">Thank You choices are private</h2><p>The fact choices are only used on the server is a powerful differentiator vs. custom Thank You JS in an <code>onSuccess</code> listener.</p><p>I love the extended power of <code>onSuccess</code><em>, </em>but it’s true that since Forms 2.0 JS runs in the browser, a curious end user can see all your possible criteria<a href="#notes" rel="noreferrer">[1]</a> and destination pages. In contrast, Thank You choices in Form Editor are private, and an end user can only see the one they matched.</p><h5 id="notes">Notes</h5>
<p>[1] Technically you can hide some criteria by matching hashes instead of plain strings, but you can’t hide the destination URLs.</p>
 ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ You can’t delete more than one cookie at a time (and  a couple other cookie bits) ]]></title>
        <description><![CDATA[ Everything old in web dev is... well, still old. But you have to know how to use it. ]]></description>
        <link>https://blog.teknkl.com/cant-delete-more-than-one-cookie-empty-value-setter/</link>
        <guid isPermaLink="false">667f3488170b320001bca49d</guid>
        <category><![CDATA[ cookies ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Sat, 06 Jul 2024 17:09:00 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/09/browserandurlstuff.png" medium="image"/>
        <content:encoded><![CDATA[ <p>Sometimes you glance at uncommented code and immediately know both <mark>what it’s trying to do (good!) and that it’s failing utterly to do it (bad!)</mark>. Might be existing programmer slang for this, but I’m gonna dub it <em>Error Code UTU8 </em>(i.e. <a href="https://knowyourmeme.com/memes/ate-that-thought-you-ate?ref=blog.teknkl.com" rel="noreferrer">you thought you ate</a>).</p><p>Like here in someone’s UTM tracking script:</p><pre><code class="language-javascript">document.cookie = "utm_campaign=, utm_medium__c=, utm_source__c=, utm_content__c=, utm_adgroup__c=, utm_keyword__c=; gclid__c=;expires=Thu, 01 Jan 1970 00:00:00 UTC;path=/;";</code></pre><p>The code above is self-evidently trying to blank out a bunch of cookies that were set individually like so:</p><pre><code class="language-javascript">document.cookie = "utm_campaign=partner-invite-2024Q2;expires=Sat, 28 Dec 2024 12:34:56 UTC;path=/;"
document.cookie = "utm_medium_c=email;expires=Sat, 28 Dec 2024 12:34:56 UTC;path=/;";
document.cookie = "utm_source_c=blog-newsletter;expires=Sat, 28 Dec 2024 12:34:56 UTC;path=/;";
// etc.</code></pre><p>Bad news for the developer: <mark>you must run the <code>document.cookie</code> setter separately for each cookie</mark>. There are no “batch actions” with cookies.</p><p>What the above code is actually doing is setting the cookie named <code>utm_campaign</code> to the value:</p><pre><code class="language-markup">, utm_medium__c=, utm_source__c=, utm_content__c=, utm_adgroup__c=, utm_keyword__c=; gclid__c=</code></pre><p>then back-dating the value. So the <code>utm_campaign</code> cookie is deleted, but no other cookies are touched.</p><p>Here’s the before:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/06/image-22.png" class="kg-image" alt="" loading="lazy" width="910" height="177" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/06/image-22.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/06/image-22.png 910w" sizes="(min-width: 720px) 720px"></figure><p>And the after:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/06/image-21.png" class="kg-image" alt="" loading="lazy" width="910" height="177" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/06/image-21.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/06/image-21.png 910w" sizes="(min-width: 720px) 720px"></figure><p> N.B. You <mark>delete a cookie by setting its expiration date in the past</mark>. The value doesn’t actually matter in this case, so <code>utm_campaign=</code> and <code>utm_campaign=whatever</code> with a past expiration have the same effect. The convention is to set to an empty string when deleting, but that’s kinda misleading because <mark>an empty cookie value is valid</mark>, as long as it’s not expired! (It’s the <code>expires</code> that deletes it, not the fact that it’s empty.)</p><h2 id="more-bits">More bits</h2><p>While I have your attention, here are a couple more bits of misguided cookie code found in the wild. (These bits, and many more, need to be wrapped up in a grand <em>Falsehoods Martech Devs Believe About Cookies™</em> post at some point.)</p><h3 id="bit-2-you-don%E2%80%99t-need-to-%E2%80%9Cwait-for%E2%80%9D-changes-via-documentcookie-to-take-effect-they%E2%80%99re-always-immediate-synchronous">Bit 2: You don’t need to “wait for” changes via <code>document.cookie</code> to take effect, they’re always immediate (synchronous)</h3><p>Another groanworthy bit is thinking the browser does some delayed/asynchronous business when you set <code>document.cookie</code>.<a href="#notes" rel="noreferrer">[1]</a> Nope, any change to <code>document.cookie</code> is <mark>committed immediately and the cookie store is up-to-date</mark> by the next line.</p><p>So you never need anything like this:</p><pre><code class="language-javascript">document.cookie = "utm_campaign=;expires=Thu, 01 Jan 1970 00:00:00 UTC;path=/;";
setTimeout(function(){
  // something that reads from document.cookie
}, 1000);</code></pre><p>Generally, such code exists because of <em>another </em>race condition — not directly related to cookies — and people panic and say “maybe the cookie wasn’t set yet?” Even if the real cause is found and fixed, this code exists for eternity, fixing nothing and just causing confusion.</p><h3 id="bit-3-you-can%E2%80%99t-set-a-cookie-to-httponly-from-js-that-just-deletes-it">Bit 3: You can’t set a cookie to <code>HttpOnly</code> from JS, that just deletes it</h3><p>Some browsers (Safari in particular) trust cookies marked <a href="https://owasp.org/www-community/HttpOnly?ref=blog.teknkl.com" rel="noreferrer"><code>HttpOnly</code></a> more than script-writeable cookies. This leads devs to think they can <mark>remove script-writeability <em>from</em> JavaScript</mark>,<mark> </mark>presumably hiding the cookie from code going forward but giving it elevated trust:</p><pre><code class="language-javascript">document.cookie = "utm_campaign=partner-invite-2024Q2;HttpOnly;expires=Sat, 28 Dec 2024 12:34:56 UTC;path=/;";</code></pre><p>This will not work. When <code>HttpOnly</code> is encountered in JS, the browser just goes, “Well, <em>that’s </em>impossible, you’re in JSland so you can’t tell me what do in HTTPland” and deletes the cookie if it exists.</p><p>(Only reason you might think this works is because, well, it <em>does</em> hide the cookie from JS, in a morbid sense!)</p><h5 id="notes">Notes</h5>
<p>[1] There <em>is </em>a new <a href="https://developer.mozilla.org/en-US/docs/Web/API/CookieStore?ref=blog.teknkl.com" rel="noreferrer">Cookie Store API</a> (not the <code>document.cookie</code> setter) that’s asynchronous, but it’s not yet widely supported.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Verify a Marketo LP domain in Google Search Console  using the “HTML file” method ]]></title>
        <description><![CDATA[ Design Studio can host static .html files. And like all DS files, their content isn’t validated before saving — handy on rare occasions! ]]></description>
        <link>https://blog.teknkl.com/google-search-console-property-marketo-lp-domain/</link>
        <guid isPermaLink="false">667bbd558fbe890001fd64de</guid>
        <category><![CDATA[ search console ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Fri, 28 Jun 2024 02:06:26 -0400</pubDate>
        <media:content url="" medium="image"/>
        <content:encoded><![CDATA[ <p>When you add a property to Google Search Console, you’ve got to prove you own the corresponding domain — or at least that you sufficiently control the webserver bound to <em>https://pages.example.com</em>.</p><p>Domain-wide verification requires a ticket opened with IT, which is best avoided. But verification of a Marketo LP domain or domain alias is something you can do yourself. There’s just one a little trick to it.</p><p>First, choose the <em>URL Prefix </em>option:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/06/image-18.png" class="kg-image" alt="" loading="lazy" width="777" height="619" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/06/image-18.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/06/image-18.png 777w" sizes="(min-width: 720px) 720px"></figure><p>Next, choose the <em>HTML file </em>option (recommended by Google) and they’ll generate a file with the extension <em><strong>.html </strong></em>that you need to serve right under <em>https://pages.example.com</em>:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/06/image-19.png" class="kg-image" alt="" loading="lazy" width="659" height="818" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/06/image-19.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/06/image-19.png 659w"></figure><p>I purposely wrote “file with the extension <strong><em>.html</em></strong>” instead of writing “HTML document.” That’s because <mark>it isn’t a valid HTML document</mark>. In fact it’s just a plain text file with one line:</p><pre><code class="language-markup">google-site-verification: google9c403852be851234.html</code></pre><p>And they really mean <em>that exact file </em>needs to be served.</p><p>Problem is, <mark>a Marketo LP Template can’t be approved if it doesn’t at least have minimal HTML5 markup</mark>. Conversely, you can’t simply surround it with valid markup like so — </p><pre><code class="language-markup">&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;meta charset="utf-8"&gt;
    &lt;title&gt;Title&lt;/title&gt;
  &lt;/head&gt;
  &lt;body&gt;
    google-site-verification: google9c403852be851234.html
  &lt;/body&gt;
&lt;/html&gt;</code></pre><p>— because Google will barf and say the file is incorrect.</p><h2 id="design-studio-to-the-rescue">Design Studio to the rescue</h2><p>The fix is pretty easy. Instead of creating a Marketo LP Template + LP, <mark>upload the file to <em>Design Studio » Images and Files</em></mark>:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/06/image-20.png" class="kg-image" alt="" loading="lazy" width="1033" height="275" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/06/image-20.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2024/06/image-20.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/06/image-20.png 1033w" sizes="(min-width: 720px) 720px"></figure><p>Then create a <em>Redirect Rule </em>in <em>Admin » Landing Pages </em>so the file appears to hang right off the LP domain root:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/06/image-17.png" class="kg-image" alt="" loading="lazy" width="613" height="281" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/06/image-17.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/06/image-17.png 613w"></figure><p>Presto:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/06/image-16.png" class="kg-image" alt="" loading="lazy" width="658" height="304" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/06/image-16.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/06/image-16.png 658w"></figure> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Never forget UTM params again with this handy email template CSS ]]></title>
        <description><![CDATA[ This post had better be a hit or I’ll be mad. ]]></description>
        <link>https://blog.teknkl.com/never-forget-utm-params-email-preview-css-markup/</link>
        <guid isPermaLink="false">66692c12392f4f00015097c7</guid>
        <category><![CDATA[ utm ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Wed, 19 Jun 2024 17:29:00 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/06/analytics-1.png" medium="image"/>
        <content:encoded><![CDATA[ <p>As we all know, adding the standard <code>utm_*</code> query params to email links is something Marketo can’t do automatically.</p><p>Storing UTM params in a Program-level <code>{{my.token}}</code> and building links from <code>${mktoString}</code> variables are smart moves. But it’s tough to tokenize every single link. (Users must remember to UTM-ify links in Rich Text areas, for example.)</p><p>While there’s no magic hack to add params for you, wouldn’t it be nice to have <mark>visual feedback when you forget to add ’em</mark>? Check this out:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/06/image-7.png" class="kg-image" alt="" loading="lazy" width="810" height="1012" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/06/image-7.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/06/image-7.png 810w" sizes="(min-width: 720px) 720px"></figure><p>It’s crazy easy to do this with modern CSS. That CSS won’t work in most email clients, but that doesn’t matter because we <mark>only need it to work in Marketo’s Email Preview</mark>!</p><p>Just add this Velocity <code>{{my.token}}</code> to your template, substituting your LP domain for <code>pages.example.com</code> and your branding (email click tracking) domain for <code>click.example.com</code>:</p><pre><code class="language-velocity">#if( $mktmail.DefaultTrackingHost.equals("localhost") )
&lt;style&gt;
.is-preview {
  /* ignore links not expected to be UTM-ified */
  a:not([href]):after, /* internal links */
  a[href^="https://pages.example.com/index.php/email/emailWebview"]:after, /* View as Web Page */
  a[href^="https://click.example.com/"]:after, /* Forward to Friend */
  a[href^="mailto:"]:after,
  a[href^="tel:"]:after,
  #mkto-social a[href]:after, /* native social widget */
  a.no-utms-expected:after /* for custom use */
  {
    content: none !important;
  }

  a.missing-utms:after 
  {
    content: "missing utm! \A" attr(href);
    white-space: pre;
    text-align: left;
    text-transform: none;
    background-color: red;
    color: white;
    font: 10px/20px Verdana,Helvetica,sans-serif;
    padding: 4px 6px;
    margin: -10px 0 0 6px;
    position: absolute;
    border-radius: 4px;
    border: 1px solid white;
  }
}
&lt;/style&gt;
&lt;script&gt;
if(document.location.hostname.endsWith(".marketodesigner.com")){
  document.addEventListener("DOMContentLoaded", function(e){
    document.documentElement.classList.add("is-preview");

    for( const link of document.links ) {
      try {
        const url = new URL(link.href);
      
        link.classList.add("missing-utms");
        for( const param of url.searchParams ){
          if( /utm_/.test(param) ) {
            link.classList.remove("missing-utms");
            break;
          }
        }
      } catch(e) {}
    };  
  });
}
&lt;/script&gt;
#end</code></pre><p>Key points:</p><ul><li>For simplicity, we <mark>require <em>at least one query param </em>starting with <code>utm_</code></mark>. The code could be expanded to specifically require <code>utm_campaign</code>, <code>utm_medium</code>, etc.</li><li>The “ignore” section contains links that aren’t expected to have UTMs.  You’ll likely find more such links. Let me know in the comments if there’s anything non-business-specific. </li><li>You can tag business-specific ignorables with <code>&lt;a class="no-utms-expected"&gt;</code>. </li><li>Make sure to check Dynamic Content w/segmentations, Velocity-generated content, etc. not just the Default preview.</li><li>Obviously you can restyle the warning any way you want, just keep <code>position: absolute</code> so it doesn’t disrupt the layout.</li></ul> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Marketo Booleans are nullable, but don’t worry about it too much ]]></title>
        <description><![CDATA[ Boolean fields aren’t only true or false, they also can be null — the unset/unknown state. Whether nullability is cool or cumbersome depends on your attitude. ]]></description>
        <link>https://blog.teknkl.com/marketo-boolean-field-nullability-data-value-changes-trigger/</link>
        <guid isPermaLink="false">6661f1e0c6f85b0001d033fa</guid>
        <category><![CDATA[ booleans ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Wed, 19 Jun 2024 17:27:55 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/06/marketo_quirks-1.png" medium="image"/>
        <content:encoded><![CDATA[ <p>A user asked about this mysterious-sounding warning in the <a href="https://experienceleague.adobe.com/en/docs/marketo/using/product-docs/core-marketo-concepts/smart-campaigns/creating-a-smart-campaign/define-smart-list-for-smart-campaign-trigger?ref=blog.teknkl.com" rel="noreferrer">Trigger Smart Campaign docs</a>:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/06/image.png" class="kg-image" alt="" loading="lazy" width="1638" height="242" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/06/image.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2024/06/image.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1600/2024/06/image.png 1600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/06/image.png 1638w" sizes="(min-width: 720px) 720px"></figure><p>Sounds scarier than it should because something’s omitted. The missing link? It’s that Boolean fields have 3 possible values: <code>true</code>, <code>false</code>, and <code>null</code>.</p><h2 id="null-enables-more-business-cases"><code>null</code> enables more business cases</h2><p>Nullability lets you distinguish — at least <em>in theory — </em><mark>an unknown or unset value</mark> from a value that is <mark>known one way or the other</mark>.</p><p>Among infinite examples:</p><ul><li>opt-in fields: responding “No” (<code>false</code>) is different from not having been asked the question (<code>null</code>)</li><li>fields like <em>Is Partner </em>before records are linked with external data: knowing somebody is not a partner (<code>false</code>) is different from not yet knowing (<code>null</code>)</li></ul><p>In practice, though, only <em>some </em>Marketo features distinguish <code>false</code> and <code>null</code>.</p><p>Sometimes <code>true</code> and <code>false</code> are the available options and <code>null</code> doesn’t match either one, which is <em>strict comparison</em>.<a href="#notes" rel="noreferrer">[1]</a> Other times <code>null</code> is temporarily treated as <code>false</code>, a <em>loose comparison</em>.</p><p>Overall, we can say Marketo is <mark>always <em>null-preserving</em><em>, </em></mark>because if you can explicitly set <code>null</code>, the value is stored in the database. But it’s <mark>not always <em>null-strict</em><em>.</em></mark> (Yes, it’s confusing. That’s why your internal Tips ’n’ Tricks doc is so key.😊)</p><h2 id="what-the-warning-means">What the warning means</h2><p>The warning above refers to one specific case: <mark>setting a value to&nbsp;<code>null</code>&nbsp;doesn't trigger&nbsp;<em>Data Value Changes</em>&nbsp;to&nbsp;<code>false</code></mark>.</p><p>Say you use a Flow step to set <code>null</code><a href="#notes" rel="noreferrer">[2]</a> or call the <em>Sync Lead </em>API endpoint like so:</p><pre><code class="language-javascript">{
  "action" : "updateOnly",
  "lookupField" : "id",
  "input" : [{
    "id" :  123456,
    "isPartner" : null
 }]
}</code></pre><p>Regardless of the previous value, this Smart Campaign trigger <em>will not </em>fire:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/06/image-14.png" class="kg-image" alt="" loading="lazy" width="1018" height="177" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/06/image-14.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2024/06/image-14.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/06/image-14.png 1018w" sizes="(min-width: 720px) 720px"></figure><p>This trigger also <em>will not </em>fire, despite attempting to compare to <code>null</code>:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/06/image-15.png" class="kg-image" alt="" loading="lazy" width="1018" height="177" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/06/image-15.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2024/06/image-15.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/06/image-15.png 1018w" sizes="(min-width: 720px) 720px"></figure><p>Yet — assuming the previous value was <code>true</code>, of course — this trigger <em>will fire</em>:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/06/image-12.png" class="kg-image" alt="" loading="lazy" width="1018" height="177" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/06/image-12.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2024/06/image-12.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/06/image-12.png 1018w" sizes="(min-width: 720px) 720px"></figure><p>As you can see, nullability adds vital flexibility to the Marketo database, yet can cause surprises<a href="#notes" rel="noreferrer">[3]</a> if you don’t test thoroughly.</p><h5 id="notes">Notes</h5>
<p>[1] It’s not usually possible to compare a field value explicitly to <code>null</code> within Smart List Filters and Flow Choices. But you must prepare for those comparisons in REST API-based apps, which will see the preserved <code>null</code> values in the language of your choice.</p><p>[2] You might not realize <em>Change Data Value </em>can set <code>null</code><em>. </em>Though the dropdown just shows <code>true</code> and <code>false</code>, you can type it manually:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/06/image-8.png" class="kg-image" alt="" loading="lazy" width="1014" height="180" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/06/image-8.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2024/06/image-8.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/06/image-8.png 1014w" sizes="(min-width: 720px) 720px"></figure><p>[3] There’s a whole world of interesting behavior with Custom Objects + <code>null</code> fields + Velocity, which I’ve written about before.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Velocitip: Find the 𝑿-th 𝒀-day (e.g. 4th Wednesday) of this month ]]></title>
        <description><![CDATA[ For all its scream-inducing quirks, I still love Velocity. Here&#39;s how to automatically compute your next recurring meeting instead of manually updating all the time. ]]></description>
        <link>https://blog.teknkl.com/velocitip-find-4th-wednesday-xth-yday-of-this-month/</link>
        <guid isPermaLink="false">6634356e1972300001aad481</guid>
        <category><![CDATA[ velocity ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Tue, 04 Jun 2024 14:46:00 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/05/velocity-marketo-logo.png" medium="image"/>
        <content:encoded><![CDATA[ <p>Your developer community meets on the 4th Wednesday of each month. You want to send monthly invites from Marketo without having to hard-code anything. Earlier, we learned how to <a href="https://blog.teknkl.com/velocitip-find-first-monday-tuesday-n-day-of-next-month/" rel="noreferrer">find the first 𝒀-day of next month</a>, and this is <em>just </em>different enough to get its own post.</p><p>As always, start with <a href="https://blog.teknkl.com/velocity-days-and-weeks/">the standard datetime includes</a> in Velocity:</p><pre><code class="language-velocity">#set( $defaultTimeZone = $date.getTimeZone().getTimeZone("America/New_York") )
#set( $defaultLocale = $date.getLocale() )
#set( $calNow = $date.getCalendar() )
#set( $ret = $calNow.setTimeZone($defaultTimeZone) )
#set( $calConst = $field.in($calNow) )
#set( $ISO8601DateOnly = "yyyy-MM-dd" )
#set( $ISO8601DateTime = "yyyy-MM-dd'T'HH:mm:ss" )
#set( $ISO8601DateTimeWithSpace = "yyyy-MM-dd HH:mm:ss" )
#set( $ISO8601DateTimeWithMillisUTC = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" )
#set( $ISO8601DateTimeWithMillisTZ = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" )</code></pre><p>Then add your campaign-specific settings, i.e. 4th Wednesday is <code>$nth = 4</code>, <code>$day = "WEDNESDAY"</code>:</p><pre><code class="language-velocity">#set( $nth = 4 )
#set( $day = "WEDNESDAY" )</code></pre><p>(<code>$day</code> must be in all-caps English, since that’s how Java’s Calendar constants are stored.)</p><p>Next the hard/weird stuff: </p><pre><code class="language-velocity">#set( $dayConst = $calConst.get($day) )
#if( $dayConst &lt; 7  ) 
#set( $nextDayConst = $math.add($dayConst,1) )
#else
#set( $nextDayConst = 1 )
#end
#set( $thisMonth = $date.getCalendar() )
#set( $ret = $thisMonth.setTimeZone($defaultTimeZone) )
#set( $ret = $thisMonth.set($calConst.DAY_OF_MONTH, 1) )
#set( $ret = $thisMonth.setFirstDayOfWeek($nextDayConst) ) ## the day *after* Y-day
#set( $ret = $thisMonth.set($calConst.DAY_OF_WEEK, $dayConst) ) ## Y-day itself
#set( $ret = $thisMonth.add($calConst.WEEK_OF_MONTH, $math.sub($nth,1) ) )</code></pre><p>Now output <code>$thisMonth</code> any way you want:</p><pre><code class="language-velocity">The ${nth}th/nd ${display.capitalize($day.toLowerCase())}
of ${date.format("MMMM yyyy", $thisMonth, $defaultLocale, $defaultTimeZone)}
is ${date.format("MM/dd/yyyy", $thisMonth, $defaultLocale, $defaultTimeZone)}</code></pre><pre><code class="language-markup">The 4th/nd Wednesday
of May 2024
is 05/22/2024</code></pre><h2 id="brief-code-explainer">Brief code explainer</h2><p>At the risk of making your head spin:</p><ol><li>Find the next day of the week after day 𝑿, using Java’s 1-based week where Sunday = 1, Monday = 2, etc.</li><li>Set the current day to <em>1st day of the month, </em>which is naturally in the 1st week of the month as well.</li><li>Shift the calendar (also done in <a href="https://blog.teknkl.com/velocitip-find-first-monday-tuesday-n-day-of-next-month/" rel="noreferrer">the previous post</a>) so weeks start on the next day from step (1). This ensures we’re in a “World of Wednesdays” if you will.</li><li>Set the current <em>day of the the week </em>to day 𝑿.</li><li>Jump forward 𝒀-1 weeks. Since we’re already in week 1 of the month, we don’t need to jump the full 𝒀.</li></ol><p>Easy, right? 😛</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ An easy-to-make, hard-to-trace mistake with Bulk Import API payloads ]]></title>
        <description><![CDATA[ A valid .csv file on disk isn’t necessarily ready to go on the wire to an API. If your app ingests CSVs, gotta remember the edge cases. ]]></description>
        <link>https://blog.teknkl.com/easy-mistake-marketo-bulk-import-api-payload/</link>
        <guid isPermaLink="false">63175ce1448307003d85de48</guid>
        <category><![CDATA[ bulk import ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Mon, 03 Jun 2024 12:59:55 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2022/09/json_url_etc.png" medium="image"/>
        <content:encoded><![CDATA[ <p>Does this look like good <code>multipart/form-data</code> for the Marketo REST API <a href="https://developers.marketo.com/rest-api/bulk-import/?ref=blog.teknkl.com">Bulk Import endpoint</a>?</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2022/09/2022-09-07-00_38_36-EditPlus--2021-11-Bynder-CV------Untitled9-_-.png" class="kg-image" alt="" loading="lazy" width="877" height="419" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2022/09/2022-09-07-00_38_36-EditPlus--2021-11-Bynder-CV------Untitled9-_-.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2022/09/2022-09-07-00_38_36-EditPlus--2021-11-Bynder-CV------Untitled9-_-.png 877w" sizes="(min-width: 720px) 720px"></figure><p>It does, right? Here it is with my text editor (EditPlus) set to show all whitespace, so you can see the CRLFs (represented as ¶) in the right places:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2022/09/2022-09-07-00_42_43-EditPlus--2021-11-Bynder-CV------Untitled9.txt---e__Downloads-.png" class="kg-image" alt="" loading="lazy" width="881" height="419" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2022/09/2022-09-07-00_42_43-EditPlus--2021-11-Bynder-CV------Untitled9.txt---e__Downloads-.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2022/09/2022-09-07-00_42_43-EditPlus--2021-11-Bynder-CV------Untitled9.txt---e__Downloads-.png 881w" sizes="(min-width: 720px) 720px"></figure><p>And yet the response from Marketo is:</p><pre><code class="language-javascript">{
    "requestId": "a571#1831bf14ce3",
    "success": false,
    "errors": [
        {
            "code": "1006",
            "message": "Header field '﻿email' not found"
        }
    ]
}</code></pre><p>Strange, eh? The header <code>email</code> is the all-important system field <em>Email Address, </em>that’s definitely its REST API name. In fact, this particular endpoint is lax about case: you can even send <code>EmAiL</code> and it’ll work... at least usually. So what’s different here?</p><h2 id="the-direct-cause">The direct cause</h2><p>Now, I’ve been deliberately using text editor screenshots to keep you guessing. I was flummoxed myself until I checked the payload in my <em>hex </em>editor (Hex Editor Neo, highly recommended). Some other apps also show you the way. For example, here’s how it looks in WebStorm:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2022/09/2022-09-07-00_53_42-doubleoptin-marketo-gateway---C__Users_swhiteman_AppData_Roaming_JetBrains_WebSt.png" class="kg-image" alt="" loading="lazy" width="676" height="472" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2022/09/2022-09-07-00_53_42-doubleoptin-marketo-gateway---C__Users_swhiteman_AppData_Roaming_JetBrains_WebSt.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2022/09/2022-09-07-00_53_42-doubleoptin-marketo-gateway---C__Users_swhiteman_AppData_Roaming_JetBrains_WebSt.png 676w"></figure><p>So there <em>is </em>an extra character: what WebStorm displays, correctly-but-not-ideally, as <code>ZWNBSP</code>, Zero Width No-Break Space.</p><p>More generally, this is the Unicode code point U+FEFF. EditPlus doesn't classify it as “whitespace”, so you don’t see it there. (They're thinking you want line breaks, tabs, common things like that as opposed to relatively obscure non-printing characters.)</p><p> That explains the error: the header is actually U+FEFF <em>followed by</em> the characters <code>e</code> <code>m</code> <code>a</code> <code>i</code> <code>l</code><a href="#notes">[1]</a>, not just the 5 characters <code>e</code> <code>m</code> <code>a</code> <code>i</code> <code>l</code>.</p><p>But this isn’t simply a random character where you chalk it up to a random glitch, delete it and go about your day. <mark>If you see it in the payload <em>once</em>, you’re sure to see it again and again, so you need to fix your code to deal with it.</mark></p>
<h2 id="zwnbsp-is-another-name-for-bom">ZWNBSP is another name for BOM</h2><p>To understand how it gets in there, and why you’ll see it again if you don’t do something, you need to think of its other identity: not <code>ZWNBSP</code> but <code>BOM</code>, Byte Order Mark. In fact, in the Unicode spec you aren’t supposed to use it for no-breaky-ness anymore,  but you can still use it for byte order:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2023/07/image-5.png" class="kg-image" alt="" loading="lazy" width="946" height="516" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2023/07/image-5.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2023/07/image-5.png 946w" sizes="(min-width: 720px) 720px"></figure><p>Now, I don’t want to dive deeply into byte order today, fascinating as it is. Suffice to say: <mark>if (and only if) U+FEFF appears at the very start of a stream of text, it’s called the BOM and determines how the rest of the text is decoded</mark>. However, for the type of text that’s supported by the Marketo REST API — that is, UTF-8 text — it’s completely unnecessary (UTF-8 is only ever encoded one way).</p>
<p>Sometimes, if the BOM is present but unnecessary, it’s gracefully ignored (and may be removed when you re-save a file). But in Marketo’s case, sending the BOM when it’s unnecessary breaks everything!</p><p>Either outcome is valid, and since you can’t know which one you’ll get with a given app, you <mark>should not include the BOM in a UTF-8 payload</mark>.</p>
<h2 id="how-did-the-bom-get-there">How did the BOM get there?</h2><p>When you see the BOM within a <code>multipart/form-data</code> part, whoever wrote the code was going for speed over everything. Performance is part of good design, to be sure. <mark>But resilience and compatibility need to be covered before you worry about performance</mark>.</p><p>They likely did something like this, in pseudo-code:</p><pre><code class="language-markup">send form-data HTTP headers
send opening MIME data (--boundary,Content-Disposition,Content-Type)
open CSV file from disk
while file chunks remain (
  read chunk
  send chunk
)
send closing MIME data (--boundary--)</code></pre><p>They knew the <code>.csv</code> file on disk was already encoded as UTF-8, same encoding they wanted to send to Marketo. So rather than wastefully reading the file into memory and decoding it, only to <em>reencode </em>it into the same sequence of bytes, they just streamed the file as-is to the API.</p><p>Now, that’s <em>absolutely </em>the most efficient way to take a file and put it on the wire. It’s silly to peek into file contents where you don’t care about them.</p><p>For example, when a webserver delivers a <code>.pdf</code> file, unless it wants to kill its own performance, it doesn’t parse the PDF structure (sections, fonts, embedded images, etc.). It pushes the bytes over the wire, from tip to tail, as fast as possible. It’s up to <em>your device </em>to read and render it, or maybe figure out it isn’t a valid PDF.</p><p>But while a PDF is clearly valid or invalid, text files are their own beast. It’s much harder to say what an “invalid text file” would be. In a broad sense (both in and out of Marketo) you’re more likely to end up with <em>mangled text values </em>than, say, a hard error message saying <code>This isn’t a good text file: Import request ignored</code>.<a href="#notes" rel="noreferrer">[2]</a></p><p>An easy <mark>but highly inefficient workaround</mark> is to read the entire file, parse it, then put it on the wire again:</p><pre><code class="language-markup">send form-data HTTP headers
send opening MIME data (--boundary,Content-Disposition,Content-Type)
open CSV file from disk
parse file, as UTF-8, to one big string
encode and send string as UTF-8
send closing MIME data (--boundary--)</code></pre><p>That’ll get rid of the BOM but at the cost of both memory and speed. You don’t need to make a drastic move like that.</p><p>Instead, you can still stream chunks, but make sure to <mark>check the first chunk for a BOM</mark>:</p><pre><code class="language-markup">send form-data HTTP headers
send opening MIME data (--boundary,Content-Disposition,Content-Type)
open CSV file from disk
while file chunks remain (
  read chunk
  if chunk is first chunk (
    set chunk = chunk without leading EF BB BF
  )
  send chunk
)
send closing MIME data (--boundary--)</code></pre><h5 id="notes">Notes</h5>
<p>[1] <code>EF</code> <code>BB</code> <code>BF</code> <code>65</code> <code>6D</code> <code>61</code> <code>69</code> <code>6C</code> in UTF-8.</p>
<p>[2] Many text encodings seen in the wild (ASCII, UTF-8, ISO-8859-1, Windows-1252, and more) aren’t sufficiently self-describing. Without firm agreement about the expected encoding — take “auto-detection” claims with a grain of salt — you‘ll end up with <strong><em>X</em></strong>% readable text peppered with <strong><em>Y</em></strong>% gibberish.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Beware the 𝚏𝚘𝚛𝚖.𝚜𝚞𝚋𝚖𝚒𝚝𝚝𝚊𝚋𝚕𝚎(𝚝𝚛𝚞𝚎) footgun ]]></title>
        <description><![CDATA[ Front end code doesn’t run in isolation, which is one reason the work is so tricky. Like when supposedly “Marketo-aware” vendors forget they’re not the only ones listening for Forms 2.0 events. ]]></description>
        <link>https://blog.teknkl.com/beware-the-form-submittable-true-footgun/</link>
        <guid isPermaLink="false">664063f3dd26c70001f5b868</guid>
        <category><![CDATA[ Marketo forms ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Wed, 15 May 2024 02:47:00 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/05/formsjs.png" medium="image"/>
        <content:encoded><![CDATA[ <p>In programming slang, a <em><strong>footgun </strong></em>is a prominent feature that’s so easy to misuse — to <em>shoot yourself in the foot</em> — that everyone should be wary of it, even if it has its uses.</p><p>Perhaps a bit strong to describe the <code>form.submittable(true)</code> method of the Forms 2.0 JS API. Yet that method is used incorrectly the majority of the time (including by yours truly!) so let’s see how to use it right.</p><p>You typically see <code>form.submittable(true)</code> used like so:</p><pre><code class="language-javascript">MktoForms2.whenReady(function(readyForm){
   readyForm.onValidate(function(isNativeValid){
      if(!isNativeValid) return;
      
      const currentValues = readyForm.getValues();

      readyForm.submittable(false);
      if( currentValues.FirstName == "Santa" ) {
         readyForm.submittable(true);
      } else {
         readyForm.showErrorMessage("Must Be Santa");      
      }
   });
});</code></pre><p>That’s a basic custom validation function: </p><ul><li><code>if(!isNativeValid)</code> makes sure the <mark>native validation rules succeeded</mark> (i.e. Form Editor rules like fields marked <em>Required </em>and string/number patterns)<ul><li>if not, exit because you want those rules to pass before running custom stuff</li><li>if so, continue</li></ul></li><li>store the current field values: <code>form.getValues()</code></li><li>set the form to <em>not </em>be submittable: <code>form.submittable(false)</code></li><li>check a custom validation rule<ul><li>if validation succeeds, set the form back to submittable with <code>form.submittable(true)</code></li><li>if validation fails, show an error message</li></ul></li></ul><p>Looks right, right? Indeed, <mark>if that’s the only custom <code>onValidate</code> function, anywhere on your page or in external scripts</mark>, you’re fine. But in reality, extended validation gets added in different places. There’s a remote <code>&lt;script src&gt;</code> that does email address validation, or you add one <code>&lt;script&gt;</code> in GTM and another <code>&lt;script&gt;</code> right on the LP, etc.</p><p>To be clear: <mark>separating validation tasks into different functions like this is best practice.</mark> But you have to write each function so it <em>knows </em>it might be in a chain of validation functions. People tend to not do that.</p><p>Look at the following <code>onValidate</code> chain, which adds another custom validation rule, making sure a <em>Checkboxes </em>field has at least 2 boxes checked:</p><pre><code class="language-javascript">&lt;script&gt;
MktoForms2.whenReady(function(readyForm){
   readyForm.onValidate(function(isNativeValid){
      if(!isNativeValid) return;
      
      const currentValues = readyForm.getValues();

      readyForm.submittable(false);
      if( currentValues.FirstName == "Santa" ) {
         readyForm.submittable(true);
      } else {
         readyForm.showErrorMessage("Must Be Santa");      
      }
   });
});
&lt;/script&gt;</code></pre><pre><code class="language-javascript">&lt;script&gt;
MktoForms2.whenReady(function(readyForm){
   readyForm.onValidate(function(isNativeValid){
      if(!isNativeValid) return;
      
      const currentValues = readyForm.getValues();

      readyForm.submittable(false);
      if( [].concat(currentValues.MeetingAvailability).length &gt;= 2 ) {
         readyForm.submittable(true);
      } else {
         readyForm.showErrorMessage("Must select at least 2 meeting times")      
      }
   });
});
&lt;/script&gt;</code></pre><p>(I’ve shown the 2 <code>&lt;script&gt;</code> tags right next to each other for simplicity, but in reality expect them to be in entirely different places.)</p><p>Can you spot the bug? Take a minute to think through possible outcomes. I’ll wait. </p><p>😴</p><p>😴</p><p>😴</p><p>OK. The bug is that <mark>if the person’s <em>First Name </em>isn’t “Santa” but they <em>did </em>check 2 meeting times, the form will submit</mark>.  While if the person’s <em>First Name </em>is “Santa” and they <em>didn’t </em>check 2 meeting times, the form won’t submit. That’s not the way it’s supposed to work: the validation rules are supposed to both succeed! </p><p>The cause of the bug is assuming that when each <code>onValidate</code> listener runs, <code>form.submittable()</code> starts out as <code>true</code>, so you restore it to <code>true</code>. That’s a bad assumption! It may start out as <code>false</code>, in which case you want to leave it that way — and don’t even bother running other validation logic.<a href="#notes" rel="noreferrer">[1]</a></p><p>At the same time, <mark>each new pass through the <code>onValidate</code> chain (e.g. after the person changes form values and re-submits) must be independent of the last pass</mark>, which might have left <code>submittable</code> set to <code>false</code>.</p><p>So you need two complementary measures:</p><p>(1) Add a <mark>first listener that that <em>only </em>sets </mark><code>form.submittable(true)</code>, nothing else.</p><p>(2) In all subsequent listeners, <mark>check if <code>form.submittable()</code> is still <code>true</code></mark> at the top of the function.</p><p>For (1), you might wonder how to ensure you’re adding the very first listener. Truth is, there’s no one-size-fits-all way — depends on your overall setup<a href="#notes" rel="noreferrer">[2]</a> — but putting it directly after the form embed<a href="#notes" rel="noreferrer">[3]</a> suffices in most cases:</p><pre><code class="language-javascript">&lt;script src="//pages.example.com/js/forms2/js/forms2.min.js"&gt;&lt;/script&gt;
&lt;form id="mktoForm_123"&gt;
&lt;script&gt;MktoForms2.loadForm("pages.example.com", "123-ABC-456", 9999);&lt;/script&gt;
&lt;script&gt;
MktoForms2.whenReady(function(readyForm){  
    readyForm.onValidate(function(isNativeValid){
      if(!isNativeValid) return;
     
      // this is the known-first listener, so set to `true`
      readyForm.submittable(true);      
    });
});
&lt;/script&gt;</code></pre><p>For (2), check submittability alongside native validity:</p><pre><code class="language-javascript">&lt;script&gt;
MktoForms2.whenReady(function(readyForm){
   readyForm.onValidate(function(isNativeValid){
      if( !isNativeValid || !readyForm.submittable() ) return;
      
      const currentValues = readyForm.getValues();      
      
      readyForm.submittable(false);      
      if( currentValues.FirstName == "Santa" ) {
         readyForm.submittable(true);
      } else {
         readyForm.showErrorMessage("Must Be Santa");
      }
   });
});
&lt;/script&gt;</code></pre><pre><code class="language-javascript">&lt;script&gt;
MktoForms2.whenReady(function(readyForm){
   readyForm.onValidate(function(isNativeValid){
      if( !isNativeValid || !readyForm.submittable() ) return;
      
      const currentValues = readyForm.getValues();

      readyForm.submittable(false);
      if( [].concat(currentValues.MeetingAvailability).length &gt;= 2 ) {
         readyForm.submittable(true);
      } else {
         readyForm.showErrorMessage("Must select at least 2 meeting times")
      }
   });
});
&lt;/script&gt;</code></pre><p>That’s it!</p><h5 id="notes">Notes</h5>
<p>[1] Some form designs require checking all validation rules, regardless of whether <code>form.submittable()</code> was already <code>false</code>. That lets you add a red border around all invalid fields at once, for example. But you <em>still </em>must make sure you <mark>don’t set <code>form.submittable(true)</code> unless every rule has passed</mark>. (The code for always-check-all-rules is more complex but you can ask about it in the comments.)</p><p>[2] Complex load sequences, like using my <a href="https://blog.teknkl.com/accurately-loading-mktoforms2-in-background-detecting-global-object/" rel="noreferrer">onMktoForms2Ready</a> pattern, make adding a known-first listener pretty tricky.</p><p>[3] On a Marketo Guided LP Template, this means directly after the <code>&lt;div class="mktoForm"&gt;</code> element (the embed code is automatically injected inside that <code>&lt;div&gt;</code>). </p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Sorting numeric-string-keyed objects in Velocity (like they would sort in JS) ]]></title>
        <description><![CDATA[ One of our clients recently did a big import of JSON-formatted values into a Textarea field. The result in Velocity, after the usual #evaluate trick to rehydrate JSON, is equivalent to this:

#set( $stringKeyedObject = {
  &quot;0&quot;: &quot;apples&quot;,
  &quot;100&quot;: &quot;oranges&quot;,
  &quot;2&quot; ]]></description>
        <link>https://blog.teknkl.com/sort-numeric-keyed-json-to-linkedhashmap/</link>
        <guid isPermaLink="false">663c25387bbea10001e1ff8d</guid>
        <category><![CDATA[ velocity ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Fri, 10 May 2024 00:32:41 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/05/velocity-marketo-logo-1.png" medium="image"/>
        <content:encoded><![CDATA[ <p>One of our clients recently did a big import of JSON-formatted values into a Textarea field. The result in Velocity, after the usual <code>#evaluate</code> trick to rehydrate JSON, is equivalent to this:</p><pre><code class="language-velocity">#set( $stringKeyedObject = {
  "0": "apples",
  "100": "oranges",
  "2": "pears"
} )</code></pre><p>(The data is structured like that for an important reason I’ll explore in a near-future post.)</p><p>Here’s the thing: the system that <em>exported </em>this data uses NodeJS. In JS, creating an object literal like</p><pre><code class="language-javascript">let o = {
  "0": "apples",
  "100": "oranges",
  "2": "pears"
};</code></pre><p>and looping over the keys with</p><pre><code class="language-javascript">Object.entries(o).forEach( ([key,value]) =&gt; console.log(key,value) )</code></pre><p>uses a specific, <a href="https://tc39.es/ecma262/2024/?ref=blog.teknkl.com#sec-ordinaryownpropertykeys" rel="noreferrer">standards-defined</a> order. Because the keys are all numeric strings (the term is <a href="https://tc39.es/ecma262/2024/?ref=blog.teknkl.com#array-index" rel="noreferrer"><em>array indexes</em></a><em> </em>in the ES standard), the entries are <mark>sorted as numbers, <em>not </em>in insertion order</mark>:</p><pre><code class="language-javascript">0 apples
2 pears
100 oranges</code></pre><p>This differs starkly from Velocity. In Velocity, for an identical-<em>looking </em>object literal, <mark>the order in a <code>#foreach</code> loop is insertion order</mark>:</p><pre><code class="language-velocity">0 apples
100 oranges
2 pears</code></pre><p>That’s because Velocity object literals always create a <a href="https://docs.oracle.com/javase/6/docs/api/java/util/LinkedHashMap.html?ref=blog.teknkl.com" rel="noreferrer">LinkedHashMap</a>, an object that maintains insertion order, regardless of the type of key.</p><p>The problem, then, is the source system <em>thinks </em>the JS order will be used to output values in a Marketo email, regardless of the order of the keys in the JSON. But that’s wrong!</p><h2 id="sorting-it-out">Sorting it out</h2><p>The solution is kind of clunky, but works fine. First, transform the object into an <em>array of objects </em>where each object has the keys <code>"idx"</code> and <code>"itm"</code>:</p><pre><code class="language-velocity">#set( $numericKeyedEntries = [] )
#foreach( $entry in $stringKeyedObject.entrySet() )
#set( $numericKeyedEntry = {
  "idx" : $convert.toInteger($entry.getKey()), 
  "itm" : $entry.getValue()
})
#set( $void = $numericKeyedEntries.add( $numericKeyedEntry ) )
#end</code></pre><p><code>"idx"</code> is the original numeric string key, cast to an Integer; the <code>"itm"</code> is the original value. So <code>$numericKeyedEntries</code> looks like this:</p><pre><code class="language-velocity">[
{
  "idx" : 0,
  "itm" : "apples"
}, 
{
  "idx" : 100,
  "itm" : "oranges"
},  
{
  "idx" : 2,
  "itm" : "pears"
}
];</code></pre><p>Now, you can use <a href="https://velocity.apache.org/tools/2.0/apidocs/org/apache/velocity/tools/generic/SortTool.html?ref=blog.teknkl.com" rel="noreferrer">SortTool</a> to sort on the <code>"idx"</code> property, which will be sorted numeric-ascending since it’s an Integer:</p><pre><code class="language-velocity">#set( $sortedNumericKeyedEntries = $sorter.sort($numericKeyedEntries,"idx") )</code></pre><p>And finally, iterate over the objects in the original intended order:</p><pre><code class="language-velocity">#foreach( $entry in $sortedNumericKeyedEntries ) 
## $entry.idx is the index
## $entry.itm is the value
#end</code></pre> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ What happens if you leave the protocol (http:// or https://) off email links completely? It’s complicated ]]></title>
        <description><![CDATA[ Outlook does the right thing sometimes, while everyone else is wrong. Crazy, I know! ]]></description>
        <link>https://blog.teknkl.com/what-happens-if-you-leave-the-protocol-off-email-links/</link>
        <guid isPermaLink="false">6604a34e0ce05f00010ea52a</guid>
        <category><![CDATA[ email clients ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Thu, 18 Apr 2024 16:04:17 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/03/client_bugs.png" medium="image"/>
        <content:encoded><![CDATA[ <p>Would you believe there’s something <em>only </em>Outlook.com and Outlook Web Access — plus a few lesser known email clients — get right?</p><p>Now, by “right” I mean <mark>obeying web standards and not covering up your mistakes</mark>. Other clients use non-standard guesswork to change what you <em>said </em>into what what they think you <em>meant. </em>And that’s bad, because <mark>if your QA happens to skip the Outlook web client</mark> that day, you’ll send a load of broken links.</p><p>Take this <code>&lt;a&gt;</code> link in a received email, which was <em>intended </em>to link to <code>https://example.com/my/page?and=query</code> but was messed up on the Marketo side:</p><pre><code class="language-markup">&lt;a href="example.com/my/page?and=query"&gt;what happens?&lt;/a&gt;</code></pre><p>Such messes happen for a couple of reasons:</p><ul><li>typing the whole link directly into the Rich Text editor, but accidentally leaving off the protocol</li><li>using a <code>{{my.token}}</code>, <code>{{lead.token}}</code> or <code>mktoString</code> variable to <em>complete </em>the <code>href</code>, but forgetting the template must contain the protocol outside the token/variable for that to work</li></ul><h2 id="a-link-without-a-protocol-doesn%E2%80%99t-have-a-hostname-either">A link without a protocol doesn’t have a hostname, either</h2><p>No matter how much it looks like a hostname, the <code>example.com</code> in <code>href="example.com"</code> is <strong>not a hostname</strong>!  It might as well be <code>abcdef</code> or <code>123456</code>. When you leave off the protocol completely, you’re <mark>in the path part of the URL</mark>.</p><p>So the URL is in fact <mark><em>path-relative</em></mark>, meaning if you were viewing <code>https://www.example.net/some/thing</code> and clicked that link, the final destination URL would be <code>https://www.example.net/some/example.com/my/page?and=query</code>. That is, <mark>the path is modified</mark>, while the hostname is untouched.</p><h2 id="mail-clients-%E2%80%9C%C2%BFqu%C3%A9-est%C3%A1ndares%E2%80%9D">Mail clients: “¿Qué estándares?”</h2><p>When I say <strong>“not a hostname”</strong> above, that’s knowing how <a href="https://www.rfc-editor.org/rfc/rfc3986?ref=blog.teknkl.com" rel="noreferrer">the URL standard</a> works. And that describes the behavior <em>on an actual webpage.</em> </p><p>But email clients don’t act like browsers opening a page — they’re different, horrific beasts. Few clients render your HTML as originally sent. Rather, they create their own HTML, merely <em>inspired by </em>your HTML — translating your <code>&lt;tr&gt;</code>s to their <code>&lt;div&gt;</code>s, your <code>&lt;td&gt;</code>s to their <code>&lt;span&gt;</code>s, and so on. (All that quirky email-specific HTML you have to learn is so it translates effectively.)</p><p>So along the way they might decide to “fix up” your URLs to make sure they point to remote locations instead of pointing to a path under <code>https://mail.google.com</code> or <code>https://mail.yahoo.com</code>. They explicitly break the standard to avoid security issues while still presenting a working link. But like I said, <mark>not every mail client does this, and you <em>cannot </em>rely on it</mark>.</p><h2 id="some-examples">Some examples</h2><p>Here’s the above link sent to Gmail:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/03/2024-03-30-15_25_33-Window-1.png" class="kg-image" alt="" loading="lazy" width="810" height="223" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/03/2024-03-30-15_25_33-Window-1.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/03/2024-03-30-15_25_33-Window-1.png 810w" sizes="(min-width: 720px) 720px"></figure><p>Here’s Gmail’s “fixed up” HTML (i.e. <code>view-source:</code> of the translated email):</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/03/2024-03-30-15_26_01-Window.png" class="kg-image" alt="" loading="lazy" width="761" height="312" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/03/2024-03-30-15_26_01-Window.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/03/2024-03-30-15_26_01-Window.png 761w" sizes="(min-width: 720px) 720px"></figure><p>As you can see, they prepended <code>http://</code> even though you messed up, so this link will most likely<a href="#notes" rel="noreferrer">✱</a> go to the originally intended URL. (Though notably <em>it’s not a Marketo-tracked link, </em>since Marketo properly didn’t see it as off-page.)</p><p>Here’s the same email sent to Outlook.com:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/03/2024-03-30-15_09_02-Window.png" class="kg-image" alt="" loading="lazy" width="782" height="221" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/03/2024-03-30-15_09_02-Window.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/03/2024-03-30-15_09_02-Window.png 782w" sizes="(min-width: 720px) 720px"></figure><p>Aha! They <em>also </em>change your original HTML, but instead of “fixing” it, they <em>neutralize it </em>so it’s no longer a link at all. This is what a wide swath of end users will experience, and you don’t want that.</p><p>Finally, here’s the same email opened in Outlook 2016:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/04/image.png" class="kg-image" alt="" loading="lazy" width="786" height="233" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/04/image.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/04/image.png 786w" sizes="(min-width: 720px) 720px"></figure><p>This one is subtly interesting because Outlook <em>doesn’t </em>change the link to include an explicit protocol. But the app passes links to your default web browser, and the <em>browser </em>then treats the link like you typed it in the Location bar, prepending <code>http://</code> like it does with user input. (Yet that’s not an official standard either, just a widely adopted convention.)</p><p>Bottom line: <mark>only use absolute URLs in emails</mark>.</p><h5 id="notes">Notes</h5>
<p>✱ Hedging with “most likely” since it’s <em>technically possible</em> for the fixed-up <code>http://example.com</code> to not redirect to <code>https://example.com</code>.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Hitting SMTP rate limits at certain domains? Put leads into fixed-size daily buckets (using FlowBoost) ]]></title>
        <description><![CDATA[ Dealing with rate limits on the recipient side is easy with FlowBoost + a little bucketing logic. (And no, you can’t do this with Random Sample!) ]]></description>
        <link>https://blog.teknkl.com/smtp-rate-limit-fixed-buckets-flowboost/</link>
        <guid isPermaLink="false">661de19899bb8a0001884b8f</guid>
        <category><![CDATA[ FlowBoost ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Thu, 18 Apr 2024 16:03:33 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/04/flowboostjs_new.png" medium="image"/>
        <content:encoded><![CDATA[ <p>A client has a pesky problem with a partner: end users are eager to get Marketo emails,  but their mailserver has strict inbound rate limits.</p><p>With several thousand Marketo records @ that domain (and growing!) they regularly see <em>Soft Bounce </em>once they exceed the daily rate limit of 1500 emails per day. It’s not tied to particular email addresses, just whoever’s unlucky enough to be person 1501+ for the day.</p><p>So we need a way to drop people into 1500-person buckets, starting with Bucket 1. Then we can space out the sends so Bucket 1 goes out on Monday, Bucket 2 on Tuesday, and so on.</p><p>And there should be the option to have different size buckets depending on the domain, in case we find another domain with a daily limit of, say, 500 or 3000.</p><p>You can’t do this with Marketo alone, but FlowBoost makes it easy. First create an Integer field <em>Staggered Sending Bucket:</em></p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/04/2024-04-16-14_58_50--UAT--Admin.png" class="kg-image" alt="" loading="lazy" width="653" height="368" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/04/2024-04-16-14_58_50--UAT--Admin.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/04/2024-04-16-14_58_50--UAT--Admin.png 653w"></figure><p>The FlowBoost payload is simple:</p><pre><code class="language-javascript">const knownDomainBucketSizes = new Map;
knownDomainBucketSizes.set("example.com", 1500); // known limit for @example.com
knownDomainBucketSizes.set("anotherexample.com", 500); // another known domain

const defaultBucketSize = 100; // if you call the ‘hook for any other domain

const emailDomain = FBUtil.string.partsFromEmail({{lead.Email Address}}).domain;
const domainBucketSize = knownDomainBucketSizes.get(emailDomain) || defaultBucketSize;

FBCounter.autoAdd(`/domainBuckets/${emailDomain}`)
.then( newEntry =&gt; {
  const bucket = Math.ceil( (newEntry.entryIndex + 1) / domainBucketSize );
  return success(bucket);
});</code></pre><p>Make sure to add the query param <code>authoringEnv=pro</code> to the end of the FlowBoost URL, as you always do when using FBCounter:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/04/image-3.png" class="kg-image" alt="" loading="lazy" width="615" height="145" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/04/image-3.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/04/image-3.png 615w"></figure><p>And map the standard <code>response</code> property to the new field:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/04/image-2.png" class="kg-image" alt="" loading="lazy" width="587" height="143"></figure><p>Then filter your sends by bucket:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/04/image-1.png" class="kg-image" alt="" loading="lazy" width="985" height="118" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/04/image-1.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/04/image-1.png 985w" sizes="(min-width: 720px) 720px"></figure><h2 id="a-little-deeper-into-the-%E2%80%9Calgorithm%E2%80%9D-above">A little deeper into the “algorithm” above</h2><p>It barely rises to the level of computer science, but the code above works like so:</p><ul><li>add a record to the counter</li><li>get that new record’s index, which gives the current total count </li><li>add 1 to the count so it’s more user-friendly (you probably want <em>Bucket 1, Bucket 2, ...</em> instead of <em>Bucket 0, Bucket 1, ...</em>)</li><li>divide the current count by the bucket size, e.g. if the count is 110 and the bucket size is 100, that’s 110/100 = 1.1</li><li>get the integral <a href="https://en.wikipedia.org/wiki/Floor_and_ceiling_functions?ref=blog.teknkl.com" rel="noreferrer">ceiling</a> of the result, e.g. <code>Math.ceil(1.1)</code> is integer 2</li><li>the ceiling is the bucket they’re in!</li></ul><h2 id="and-no-random-sample-can%E2%80%99t-do-this">And no, <em>Random Sample </em>can’t do this</h2><p>Marketo’s <em>Random Sample </em>flow step <mark>can only do percentages, not fixed sizes</mark>. As the audience is ever-growing, it’s <mark>impossible to use it for this case</mark>. <em>Random Sample </em>is for when you have a fixed-size list/one-time send and can pre-compute a % that approximates the bucket size.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Verifying the mkt_tok was added to Marketo links ]]></title>
        <description><![CDATA[ A quick old-school troubleshooting tip. ]]></description>
        <link>https://blog.teknkl.com/verifying-the-mkt_tok-view-source/</link>
        <guid isPermaLink="false">66034f14bf15f90001dd234d</guid>
        <category><![CDATA[ clicked email ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Tue, 26 Mar 2024 21:19:52 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/03/browserandurlstuff.png" medium="image"/>
        <content:encoded><![CDATA[ <p>These days, anything is up for debate, it seems: there are even <code>mkt_tok</code> denialists!</p><p>An IT team claimed the all-important <code>mkt_tok</code> query param<a href="#notes" rel="noreferrer">✱</a> wasn’t being attached to Marketo links, even though they redirected from Marketo’s click tracking server (a.k.a. branding domain) to the right page. We knew this was vanishingly unlikely, but how to prove it?</p><p>Granted, <code>mkt_tok</code> can be confusing on a Marketo LP because there’s system-managed JavaScript that strips the param when the page loads, moving it to a JS variable for security reasons. The <code>mkt_tok</code> is still in the location bar for an instant, but you might miss it.</p><p>However, in the current case the target was <mark>their corporate website, where that <code>mkt_tok</code>-stripping code wouldn’t be present</mark>.<a href="#notes" rel="noreferrer">✱✱</a></p><p>So we gotta bring back an old-school technique. Copy the link from the received email, prepend <code>view-source:</code>, and open it. You can see the <code>mkt_tok</code> is there as expected: </p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/03/image-16.png" class="kg-image" alt="" loading="lazy" width="1577" height="326" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/03/image-16.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2024/03/image-16.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/03/image-16.png 1577w" sizes="(min-width: 720px) 720px"></figure><p>Turned out in this case their webserver was redirecting people <em>again </em>from <code>https://www.example.com/preferencecenter</code> to <code>https://example.com/en/preferences</code> but was <mark>stripping all query params except those beginning with </mark><code>utm_</code>! Solved.</p><h5 id="notes">Notes</h5>
<p>✱ In case you forgot, without the <code>mkt_tok</code> Munchkin can’t associate anonymous web sessions, so <em>Visited Web Page </em>and <em>Clicked Link </em>won’t be logged. <em>Clicked</em> <em>Email</em> doesn’t use Munchkin, so isn’t directly affected.</p><p>✱✱ Note if someone has Firefox’s <a href="https://firefox-source-docs.mozilla.org/toolkit/components/antitracking/anti-tracking/query-stripping/index.html?ref=blog.teknkl.com" rel="noreferrer">Query Parameter Stripping</a> enabled, that’ll strip <code>mkt_tok</code> at the browser level. However, this is a Chrome-centric shop and that’s a non-default FF setting anyway.</p> ]]></content:encoded>
    </item>
    <item>
        <title><![CDATA[ Embedding a Marketo form on a Framer LP ]]></title>
        <description><![CDATA[ WYSIWYG LP builders make cool pages out of the box, but trying to add custom code can evoke mixed emotions. ]]></description>
        <link>https://blog.teknkl.com/embedding-a-marketo-form-on-a-framer-lp/</link>
        <guid isPermaLink="false">6601c921bf15f90001dd227e</guid>
        <category><![CDATA[ Framer ]]></category>
        <dc:creator><![CDATA[ Sanford Whiteman ]]></dc:creator>
        <pubDate>Tue, 26 Mar 2024 00:46:39 -0400</pubDate>
        <media:content url="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/03/formsjs.png" medium="image"/>
        <content:encoded><![CDATA[ <p>Framer LPs support custom code, but — as with other WYSIWYG site builders — the DX is pretty unfriendly. <em>Insert » Utility » Embed </em>gives you a miniature textarea with a proportional font (a pet peeve) and no syntax highlighting:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/03/2024-03-25-15_18_51-Window.png" class="kg-image" alt="" loading="lazy" width="527" height="269"></figure><p>Pasting a standard Marketo Forms 2.0 embed code into the textarea looks fine in Framer’s Preview mode:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/03/2024-03-25-15_21_11-Window.png" class="kg-image" alt="" loading="lazy" width="1243" height="711" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/03/2024-03-25-15_21_11-Window.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2024/03/2024-03-25-15_21_11-Window.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/03/2024-03-25-15_21_11-Window.png 1243w" sizes="(min-width: 720px) 720px"></figure><p>But on the published LP, the form is nowhere to be found:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/03/2024-03-25-15_21_41-Window.png" class="kg-image" alt="" loading="lazy" width="1001" height="636" srcset="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w600/2024/03/2024-03-25-15_21_41-Window.png 600w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/size/w1000/2024/03/2024-03-25-15_21_41-Window.png 1000w, https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/03/2024-03-25-15_21_41-Window.png 1001w" sizes="(min-width: 720px) 720px"></figure><p>There’s a very clear clue in the F12 Console:</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/40/60/40609251-b7ca-4b81-a31d-b90f1ae82433/content/images/2024/03/2024-03-25-15_22_25-Window.png" class="kg-image" alt="" loading="lazy" width="563" height="226"></figure><p>~60% of the time, this error means you have <mark>a persistent error in script load order</mark>.<a href="#notes" rel="noreferrer">✱</a></p><p>Indeed, on the published page, Framer <mark>injects the HTML embed asynchronously</mark> (I assume for what they think are “performance” reasons). The Forms 2.0 library (the  remote <code>&lt;script src=".../forms2.min.js"&gt;</code> thus loads in the background, while the inline <code>&lt;script&gt;</code> is executed immediately.<a href="#notes" rel="noreferrer">✱</a><a href="#notes" rel="noreferrer">✱</a> That’s why it’ll never find <code>MktoForms2</code>.</p><p>In preview and in the builder, though, the HTML embed is served in the original document. Pretty confusing.</p><p>Anyway, it’s very easy to fix with the approach from my post on <a href="https://nation.marketo.com/t5/product-blogs/accurately-background-loading-the-mktoforms2-global-object/ba-p/309609?ref=blog.teknkl.com" rel="noreferrer">accurately awaiting the Forms 2.0 global object</a>. Use this HTML block instead:</p><pre><code class="language-javascript">&lt;script src="//app-sj01.marketo.com/js/forms2/js/forms2.min.js"&gt;&lt;/script&gt;
&lt;form id="mktoForm_9999"&gt;&lt;/form&gt;
&lt;script&gt;function doOrQueueFormsStuff() {
  if (typeof MktoForms2 != "object") {
    document.addEventListener("load", doOrQueueFormsStuff, true);
  } else if (!doOrQueueFormsStuff.done) {
    document.removeEventListener("load", doOrQueueFormsStuff, true);
    doOrQueueFormsStuff.done = true;
    MktoForms2.loadForm("//app-sj01.marketo.com", "123-ABC-456", 9999)
  }
}
doOrQueueFormsStuff();
&lt;/script&gt;
</code></pre><h5 id="notes">Notes</h5>
<p>✱ The other 40% of the time, it’s a race condition, which is different because the load order is <em>unpredictable,</em> based on network and browser conditions. A <mark>persistent error means the order is predictable and always wrong</mark>!</p><p>✱✱ As we’ve learned before, a script-injected inline <code>&lt;script&gt;</code> must be deliberately re-run by setting its <code>text</code> property, so there’s nothing accidental in this setup.</p> ]]></content:encoded>
    </item>

</channel>
</rss>