HubSpot is one of, if not the most, widely used CRM and marketing automation platforms in the world. And HubSpot forms are currently powering ~224,000+ live websites according to BuiltWith. This makes it crucial to be able to track lead generation and form submissions for any marketer using this tool. And that’s what we will be covering in this article.
HubSpot Forms - History & Changes
HubSpot forms have evolved over the years as any good product would. But, with the releases came errors in tracking, broken funnel tracking and most importantly unoptimized ad spend for lead acquisition campaigns which leads in most cases to an unfathomable customer acquisition cost and a very low return on ad spend.
In the past, HubSpot forms were tracked using their famous Global Events which used messages to inform you of the different stages of the form. We will not delve into this in depth but these global events were emitted by HubSpot and they were the following:
- OnFormReady
- OnBeforeFormSubmit
- onFormSubmit These events helped you understand when the form was loaded and when it was successfully submitted. If you want to go deeper, we have a whole article covering these events.
Important
Please note that Global Events can still be used to track legacy forms. Please do not migrate your tracking code unless you know you are using Forms V4.
If you are unsure which version you are using, there are ways to find out. The first is typing HubSpot in your console. If you see HubSpotForms then you are using legacy forms. If you see HubSpotFormsV4 then you are using the new version.
The second way, and the preferred one for nerdy reasons, is to log the messages emitted by HubSpot. If you are using the old version, you will see a multitude of messages in your console. If you see nothing coming from HubSpot then you are using Forms V4.
Here’s an example of one of the emitted messages:
{
isTrusted: true,
data: {
type: "hsFormCallback",
eventName: "onFormReady",
id: "414c3f35-bd01-4403-a26f-10adf7d98873",
data: {}
},
origin: "https://www.datakyu.co",
ports: [],
returnValue: true,
source: Window,
srcElement: Window,
target: Window,
timeStamp: 17444.90000000596,
type: "message"
}
This event can be used for instance to track an event confirming that the form was loaded and ready for input.
However, things have changed a bit with the newest version of HubSpot forms. If you did try to run window.addEventListener("message", console.log) you would have noticed that there are no more messages coming from HubSpot.
This is because they have dropped the hsFormCallback event entirely and replaced it with native DOM custom events. This means that you are listening to something that does not exist anymore and thus your tracking completely breaks.
And the worst part is that this will break silently without any error or warning. You will just see that your form submission tracking is not working anymore and you won’t know why.
What is the difference between Global Events and Custom Events?
Global Events are messages sent from HubSpot to the window object to let you know that something is happening with the form. It’s like having a radio broadcast from HubSpot transmitting everything to you.
Custom Events, on the other hand, are emitted by the form itself and need to be specified upfront. Think of it like tuning to a specific frequency.
You need to specify what you want to listen to and how to react to it otherwise all you will hear is static.
HubSpot Forms V4 - Custom Events
With the new forms version, the events exposed to the user have changed. You now have more granular events to listen to. Here are the available events:
| Event name | Description | Legacy equivalent |
|---|---|---|
hs-form-event:on-ready | The form has finished loading and is fully rendered | onFormReady |
hs-form-event:on-submission:success | The form has been submitted and the submission is successful | onFormSubmit |
hs-form-event:on-submission:failed | The form submission failed | |
hs-form-event:on-interaction:navigate | For multi-step forms, the user has navigated between steps |
Important
In addition to the navigate event, you can listen for hs-form-event:on-interaction:navigate:next or hs-form-event:on-interaction:navigate:previous for easier tracking
There are two details that should be noted here. The first one is that the OnBeforeFormSubmit event is gone. The second and the most important is the event taxonomy. Let’s break it down:
- All events have the prefix
hs-form-event:which is the namespace for all events related to HubSpot forms. - Form action which is either
on-ready,on-submissionoron-interaction. This is the main action that is happening with the form. - Status or type of the action. This is only applicable for some actions. For instance, for
on-submissionyou havesuccessandfailedstatus. Foron-interaction:navigateyou havenextandpreviousstatus.
The interaction action has an additional level of precision which is navigate and this is mainly for multi-step forms. This means that you can now track when a user navigates between steps in a multi-step form which is a game changer for tracking and optimizing multi-step forms.
Code Migration - From Global Events to Custom Events
For the migration, we will cover the form submission first and then we will cover the other events. The form submission event is the most critical for lead generation tracking and so we will cover it in more depth and introduce some advanced concepts such as identity resolution, identity stitching and enhanced conversion tracking. Let’s get to it.
Available data from Global Events vs Custom Events
The first thing we will look at is the difference in available data when the event enters our radar. That is to say, once we know the form was successfully submitted what data is available to work with.
Global Events
The onFormSubmitted event offers a lot of data you can work with. Here’s a full payload of the event:
{
isTrusted: true,
type: "message",
origin: "https://www.example.com",
timeStamp: 199831.00000000003,
data: {
type: "hsFormCallback",
eventName: "onFormSubmitted",
id: "414c3f35-bd01-4403-a26f-10adf7d98873",
data: {
conversionId: "3ea48954-3546-4976-9541-2793bdd39bb4",
formGuid: "414c3f35-bd01-4403-a26f-10adf7d98873",
redirectUrl: "",
submissionValues: {
firstname: "Jon",
lastname: "Doe",
email: "jon+doet@hiredatakyu.com",
company: "",
message: "",
mobilephone: "111 111 1111",
}
}
}
}
What’s actually usable in this payload is the following:
{
eventName: "onFormSubmitted",
id: "414c3f35-bd01-4403-a26f-10adf7d98873",
conversionId: "3ea48954-3546-4976-9541-2793bdd39bb4",
submissionValues: {
email: "jon+doet@hiredatakyu.com"
}
}
You can access said values as such:
const {eventName, id, data: {conversionId, submissionValues: {email}}} = event.data;
If you are doing some Account-based Marketing you can add the company name, first and last name to your tracking. With this payload, you can implement user_id tracking with MD5 hashes on the submitted email,
you can also implement identity resolution with tools such as Amplitude, Mixpanel or Segment by calling their identify() method. You can also implement enhanced conversion tracking with Google Ads
by sending the hashed email and other data to Google Ads API. The possibilities are endless. From a single payload you can turn the data into a complete flywheel.
Custom Events
Now that we have seen what is doable with the old payload, it’s time to see what the new custom events are offering. Let’s check what we can access with the new tracking implementation.
window.addEventListener("hs-form-event:on-submission:success", async function(event) {
const form = HubSpotFormsV4.getFormFromEvent(event);
const fields = await form.getFormFieldValues();
const formId = form.getFormId();
});
There is a fundamental change in this tracking call that you need to pay close attention to. In the old implementation the data was accessible instantly in the payload, in the new tracking though there is the
await in front of the form.getFormFieldValues(). There is also async in front of the callback function being executed when the event is detected. This is extremely important as you need to familiarize yourself with
asynchronous JavaScript and the concept of Promises to be able to work with the new tracking implementation. Accessing fields before resolution or at the wrong timing may lead to unexpected results and data loss.
The getFormFieldValues() method is a promise that resolves to an array containing all the form fields and their values. That means that you can access the same data as before but you need to wait for the promise to resolve before you can access it.
The resolved promise looks something like this:
[
{
"name": "0-1/firstname",
"value": "HubSpot"
},
{
"name": "0-1/multicheckbox",
"value": ["yes", "no"]
}
]
You can also access individual fields by their name. This requires knowing the name of the field you need its value returned so you might want to check the form configuration prior to using this. Here’s how you can get an individual field value:
HubSpotFormsV4.getForms()[0].getFieldValue("email").then(value => value);
//or
await HubSpotFormsV4.getFormFromEvent(event).getFormFieldValue("email");
Once you have access to field values, you can use them in the same way as before to implement user_id tracking, identity resolution and enhanced conversion tracking. The only difference is that you need to wait for the promise to resolve before you can access the data. This is a fundamental change in the way you need to think about tracking and data collection with HubSpot forms. Another important note is going to be how to handle this operation in Google Tag Manager.
At this point, we are going to take a slight detour and explain how to handle asynchronous operations in Google Tag Manager. This is not a requirement for tracking HubSpot forms submissions with the new implementation but it’s good to know how to handle this in case you need to do any data fetching or processing before logging events in the dataLayer.
Google Tag Manager & Asynchronous Operations
Google Tag Manager is not the best place to handle asynchronous code, however, because this is something that is getting executed on an event listener provided from HubSpot, you will not need to worry about handling the cleanup and the intricacies of running promises in GTM using the custom HTML tag - which is what you are going to be using if you need to add this listener to your form pages.
Running asynchronous code in Google Tag Manager
For the sake of clarity, we will show you how and what it takes to run asynchronous operations or code in Google Tag Manager. A word to the wise, this is about to get into some delicate and complex territory so if you are not comfortable with JavaScript, skip this part. This is more of an FYI than a requirement.
In Google Tag Manager, Custom HTML tags do not wait for Promises to be resolved. That means Google Tag Manager is not going to wait before moving to the next tag or event. In order to handle this properly, you will need to use Google Tag Manager’s built-in callback functions to signal that an asynchronous operation is complete. To do so, you will need to enable two built-in variables first:
- Container ID
- HTML ID Then, in your custom HTML tag, you will need to work as follows:
<script>
(function() {
// Reference GTM's internal callback functions
var gtmSuccess = window.google_tag_manager[{{Container ID}}].onHtmlSuccess;
var gtmFailure = window.google_tag_manager[{{Container ID}}].onHtmlFailure;
var htmlId = {{HTML ID}};
// Your asynchronous operation
someAsyncFunction()
.then(function(result) {
console.log("Success:", result);
// Signal GTM that the tag is done
gtmSuccess(htmlId);
})
.catch(function(err) {
console.error("Error:", err);
// Signal GTM that the tag failed
gtmFailure(htmlId);
});
})();
</script>
This is very important if you want to avoid your tag being stuck in Still Running indefinitely. If you are fetching data from an external API like 6sense’s API for data augmentation, once you have the data accessible, you can still log events in the dataLayer and GTM will be able to pick them up and use them for firing tags. Again, this is not a requirement for tracking HubSpot forms submissions, but it’s good to know how to handle asynchronous code in GTM if you need to do any data fetching or processing before logging events in the dataLayer. And to reiterate, you will not have to worry about handling this with HubSpot’s new tracking implementation as the event listener is provided by HubSpot and it will handle the asynchronous operations for you. You just need to make sure to wait for the promise to resolve before accessing the data.
Other Custom Events
As mentioned earlier, the other events available with the new tracking implementation will work exactly in the same way as the form submission event. The only thing you have to do is to change the event you are listening for and the tracking will work. For instance, if you want to track when someone moves to the next step of a multi-step form, you can do as such:
window.addEventListener("hs-form-event:on-interaction:navigate:next", async function(event){
var form = HubSpotFormsV4.getFormFromEvent(event);
var fields = await form.getFormFieldValues();
var formId = form.getFormId();
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: "hubspot_form_step_navigated",
formId: formId,
fields: fields
});
})
Please do not pass the fields dataLayer variable as a value to a Google Analytics 4 event parameter as GA4 is not built to process objects or arrays (with the exception of the ecommerce object and items array). You can traverse the fields object as you would a regular object and pass specific values as event parameters.
Conclusion
With this new tracking implementation, you have more control and precision over your form, its data and how you handle the different states or stages of the form. From identity resolution through user_id tracking and identify calls, ABM-friendly tracking by passing company name and user details to enhanced conversion tracking for advertising platforms, you can use the new payload to implement a host of basic to advanced tracking implementations. The only thing you need to be careful about is handling the asynchronous nature of the new tracking implementation and making sure to wait for the promises to resolve before accessing the data.
MD5 Hashing for Identity Resolution and User_id Tracking
If you need to implement identity resolution or user_id tracking and you do not want to pass personally identifiable information to your analytics or advertising platforms, you can use the MD5 hash of the email as the user_id or identity. And since most work with Google Tag Manager, here’s a full code snippet that will help you implement user_id tracking with MD5 hashes in GTM:
!(function (n) {
"use strict";
function d(n, t) {
var r = (65535 & n) + (65535 & t);
return (((n >> 16) + (t >> 16) + (r >> 16)) << 16) | (65535 & r);
}
function f(n, t, r, e, o, u) {
return d(((u = d(d(t, n), d(e, u))) << o) | (u >>> (32 - o)), r);
}
function l(n, t, r, e, o, u, c) {
return f((t & r) | (~t & e), n, t, o, u, c);
}
function g(n, t, r, e, o, u, c) {
return f((t & e) | (r & ~e), n, t, o, u, c);
}
function v(n, t, r, e, o, u, c) {
return f(t ^ r ^ e, n, t, o, u, c);
}
function m(n, t, r, e, o, u, c) {
return f(r ^ (t | ~e), n, t, o, u, c);
}
function c(n, t) {
var r, e, o, u;
(n[t >> 5] |= 128 << t % 32), (n[14 + (((t + 64) >>> 9) << 4)] = t);
for (
var c = 1732584193, f = -271733879, i = -1732584194, a = 271733878, h = 0;
h < n.length;
h += 16
)
(c = l((r = c), (e = f), (o = i), (u = a), n[h], 7, -680876936)),
(a = l(a, c, f, i, n[h + 1], 12, -389564586)),
(i = l(i, a, c, f, n[h + 2], 17, 606105819)),
(f = l(f, i, a, c, n[h + 3], 22, -1044525330)),
(c = l(c, f, i, a, n[h + 4], 7, -176418897)),
(a = l(a, c, f, i, n[h + 5], 12, 1200080426)),
(i = l(i, a, c, f, n[h + 6], 17, -1473231341)),
(f = l(f, i, a, c, n[h + 7], 22, -45705983)),
(c = l(c, f, i, a, n[h + 8], 7, 1770035416)),
(a = l(a, c, f, i, n[h + 9], 12, -1958414417)),
(i = l(i, a, c, f, n[h + 10], 17, -42063)),
(f = l(f, i, a, c, n[h + 11], 22, -1990404162)),
(c = l(c, f, i, a, n[h + 12], 7, 1804603682)),
(a = l(a, c, f, i, n[h + 13], 12, -40341101)),
(i = l(i, a, c, f, n[h + 14], 17, -1502002290)),
(c = g(
c,
(f = l(f, i, a, c, n[h + 15], 22, 1236535329)),
i,
a,
n[h + 1],
5,
-165796510,
)),
(a = g(a, c, f, i, n[h + 6], 9, -1069501632)),
(i = g(i, a, c, f, n[h + 11], 14, 643717713)),
(f = g(f, i, a, c, n[h], 20, -373897302)),
(c = g(c, f, i, a, n[h + 5], 5, -701558691)),
(a = g(a, c, f, i, n[h + 10], 9, 38016083)),
(i = g(i, a, c, f, n[h + 15], 14, -660478335)),
(f = g(f, i, a, c, n[h + 4], 20, -405537848)),
(c = g(c, f, i, a, n[h + 9], 5, 568446438)),
(a = g(a, c, f, i, n[h + 14], 9, -1019803690)),
(i = g(i, a, c, f, n[h + 3], 14, -187363961)),
(f = g(f, i, a, c, n[h + 8], 20, 1163531501)),
(c = g(c, f, i, a, n[h + 13], 5, -1444681467)),
(a = g(a, c, f, i, n[h + 2], 9, -51403784)),
(i = g(i, a, c, f, n[h + 7], 14, 1735328473)),
(c = v(
c,
(f = g(f, i, a, c, n[h + 12], 20, -1926607734)),
i,
a,
n[h + 5],
4,
-378558,
)),
(a = v(a, c, f, i, n[h + 8], 11, -2022574463)),
(i = v(i, a, c, f, n[h + 11], 16, 1839030562)),
(f = v(f, i, a, c, n[h + 14], 23, -35309556)),
(c = v(c, f, i, a, n[h + 1], 4, -1530992060)),
(a = v(a, c, f, i, n[h + 4], 11, 1272893353)),
(i = v(i, a, c, f, n[h + 7], 16, -155497632)),
(f = v(f, i, a, c, n[h + 10], 23, -1094730640)),
(c = v(c, f, i, a, n[h + 13], 4, 681279174)),
(a = v(a, c, f, i, n[h], 11, -358537222)),
(i = v(i, a, c, f, n[h + 3], 16, -722521979)),
(f = v(f, i, a, c, n[h + 6], 23, 76029189)),
(c = v(c, f, i, a, n[h + 9], 4, -640364487)),
(a = v(a, c, f, i, n[h + 12], 11, -421815835)),
(i = v(i, a, c, f, n[h + 15], 16, 530742520)),
(c = m(
c,
(f = v(f, i, a, c, n[h + 2], 23, -995338651)),
i,
a,
n[h],
6,
-198630844,
)),
(a = m(a, c, f, i, n[h + 7], 10, 1126891415)),
(i = m(i, a, c, f, n[h + 14], 15, -1416354905)),
(f = m(f, i, a, c, n[h + 5], 21, -57434055)),
(c = m(c, f, i, a, n[h + 12], 6, 1700485571)),
(a = m(a, c, f, i, n[h + 3], 10, -1894986606)),
(i = m(i, a, c, f, n[h + 10], 15, -1051523)),
(f = m(f, i, a, c, n[h + 1], 21, -2054922799)),
(c = m(c, f, i, a, n[h + 8], 6, 1873313359)),
(a = m(a, c, f, i, n[h + 15], 10, -30611744)),
(i = m(i, a, c, f, n[h + 6], 15, -1560198380)),
(f = m(f, i, a, c, n[h + 13], 21, 1309151649)),
(c = m(c, f, i, a, n[h + 4], 6, -145523070)),
(a = m(a, c, f, i, n[h + 11], 10, -1120210379)),
(i = m(i, a, c, f, n[h + 2], 15, 718787259)),
(f = m(f, i, a, c, n[h + 9], 21, -343485551)),
(c = d(c, r)),
(f = d(f, e)),
(i = d(i, o)),
(a = d(a, u));
return [c, f, i, a];
}
function i(n) {
for (var t = "", r = 32 * n.length, e = 0; e < r; e += 8)
t += String.fromCharCode((n[e >> 5] >>> e % 32) & 255);
return t;
}
function a(n) {
var t = [];
for (t[(n.length >> 2) - 1] = void 0, e = 0; e < t.length; e += 1) t[e] = 0;
for (var r = 8 * n.length, e = 0; e < r; e += 8)
t[e >> 5] |= (255 & n.charCodeAt(e / 8)) << e % 32;
return t;
}
function e(n) {
for (var t, r = "0123456789abcdef", e = "", o = 0; o < n.length; o += 1)
(t = n.charCodeAt(o)), (e += r.charAt((t >>> 4) & 15) + r.charAt(15 & t));
return e;
}
function r(n) {
return unescape(encodeURIComponent(n));
}
function o(n) {
return i(c(a((n = r(n))), 8 * n.length));
}
function u(n, t) {
return (function (n, t) {
var r,
e = a(n),
o = [],
u = [];
for (
o[15] = u[15] = void 0,
16 < e.length && (e = c(e, 8 * n.length)),
r = 0;
r < 16;
r += 1
)
(o[r] = 909522486 ^ e[r]), (u[r] = 1549556828 ^ e[r]);
return (
(t = c(o.concat(a(t)), 512 + 8 * t.length)), i(c(u.concat(t), 640))
);
})(r(n), r(t));
}
function t(n, t, r) {
return t ? (r ? u(t, n) : e(u(t, n))) : r ? o(n) : e(o(n));
}
"function" == typeof define && define.amd
? define(function () {
return t;
})
: "object" == typeof module && module.exports
? (module.exports = t)
: (n.md5 = t);
})(this);