A B2B client came to me frustrated that “every demo booking says it came from Calendly.” Their sales team was closing deals from these bookings, but in GA4 the source was always calendly.com / referral — not the Google Ads click or the organic search that actually drove the person to the site. They were spending real money on ads and had no idea which channel was producing booked meetings, because Calendly’s own GA4 integration was overwriting the truth with a self-referral.
This is the single most common Calendly tracking problem I see, and it’s completely fixable. The native integration is easy to switch on, but it fires the conversion from Calendly’s domain, so GA4 attributes the booking to Calendly instead of the channel that sent the visitor. Track it yourself through GTM instead, and the booking gets credited to the real source — because the event fires on your site, where the session’s original attribution lives.
Here’s how I set it up.
Why Calendly needs a listener in the first place
When you embed Calendly, that calendar is technically a separate webpage running inside yours — an iframe. Browser security (the same-origin policy) means your main page, and therefore GTM, can’t reach inside that iframe to see what someone clicks. So a normal trigger can’t catch a booking.
Calendly solves this from their side using the browser’s postMessage API: the widget broadcasts messages to the parent page whenever a key action happens. What we build is a small listener that catches those messages and pushes them into the data layer, where GTM can finally use them. That’s the whole trick — the listener is the bridge between Calendly’s iframe and your GTM.
The four events you can track
Calendly’s messages let you capture four distinct steps, which together map the entire booking funnel:
profile_page_viewed— the calendar loaded on your pageevent_type_viewed— someone opened a specific meeting typedate_and_time_selected— they picked a slotevent_scheduled— they actually booked
Tracking all four (not just the booking) lets you build a funnel in GA4 and see where people drop off — for example, plenty viewing the calendar but few selecting a time points to friction in the slots or availability. That’s the difference between measuring an outcome and understanding a process.
One honest limitation: this method tells you that someone viewed or booked, not the exact meeting type or time slot. That granularity isn’t something Calendly exposes through these messages, so don’t promise a client slot-level reporting from this approach.
Step 1: The listener (Custom HTML tag)
In GTM, create a Custom HTML tag and paste in the listener below. It does three important things: it verifies the message genuinely came from Calendly’s origin (so a spoofed postMessage can’t inject fake events), it passes the full Calendly event name straight through as your GTM trigger name, and it keeps the entire payload intact under one clean key for your variables.
<script>
/* ============================================================
* Calendly → dataLayer Listener
* Author : Tuhin (Md Sarwar Hossain)
* Brand : AnalyticsRush.com
* Purpose: Push Calendly embed events into GTM dataLayer
* ============================================================ */
window.addEventListener('message', function (event) {
// Only trust messages coming from Calendly
if (event.origin !== 'https://calendly.com') return;
var data = event.data;
// Ignore anything that isn't a real Calendly event
// (Calendly events always look like: { event: 'calendly.event_scheduled', payload: {...} })
if (!data || typeof data.event !== 'string' || data.event.indexOf('calendly.') !== 0) return;
window.dataLayer = window.dataLayer || [];
dataLayer.push({
event: data.event, // e.g. calendly.event_scheduled → your GTM trigger name
calendlyPayload: data.payload // full payload kept clean for GTM variables
});
});
</script>
Fire this tag on the page(s) where Calendly is embedded. All Pages works, but if the calendar only lives on one URL, use a Page View trigger with a Page URL contains condition — no reason to run the listener everywhere.
Two things worth understanding about how this listener behaves, because they shape the next steps:
- It’s strict about origin. The
event.origin !== 'https://calendly.com'check is good security — it only trusts real Calendly messages. Just remember it if you ever debug a case where nothing fires (more on that in testing). - It passes every
calendly.*event through, including Calendly’s noisypage_heightmessages (the widget emits those constantly as it resizes) and occasional duplicateevent_type_viewedevents. Rather than filter those in the JavaScript, we handle them cleanly at the trigger level in the next step — which keeps the listener simple and generic.
Step 2: One Custom Event trigger (that also filters the noise)
Because the listener pushes the full event name (like calendly.event_scheduled) as the data layer event, your GTM trigger matches on that.
Create a Custom Event trigger, tick Use regex matching, and set the event name to:
calendly\.(profile_page_viewed|event_type_viewed|date_and_time_selected|event_scheduled)
This does two jobs at once. It fires for all four real Calendly actions from a single trigger, and — because page_height isn’t in the list — it quietly ignores Calendly’s resize spam. No extra filtering needed, and your listener code stays untouched.
(If you’d rather handle each action separately, you can instead make one trigger per event name — calendly.event_scheduled, and so on — exactly as the listener’s comment suggests. The regex approach above is just the tidier default.)
Step 3: Turn the event name into a valid GA4 name
Here’s a detail that trips people up with this pass-through approach: your data layer event is calendly.event_scheduled, but GA4 event names can’t contain periods — they must start with a letter and use only letters, numbers, and underscores. Send a dotted name and GA4 will reject or mangle it.
So we transform it. Create a Custom JavaScript variable — call it Calendly Event Name:
function () {
var e = {{Event}};
// calendly.event_scheduled → calendar_event_scheduled
return e ? e.replace('calendly.', 'calendar_') : undefined;
}
{{Event}} is GTM’s built-in variable holding the current data layer event name. This swaps the calendly. prefix for calendar_, giving you clean, GA4-valid names like calendar_event_scheduled and calendar_profile_page_viewed.
(Prefer no custom JavaScript? A RegEx Table variable with input {{Event}}, pattern calendly\.(.+), and output calendar_$1 does the same thing natively.)
Step 4: One GA4 tag for all four events
Now the elegant part — you don’t need four tags. Create a single GA4 Event tag:
- Event Name:
{{Calendly Event Name}}(the variable from Step 3) - Trigger: the Custom Event trigger from Step 2
- Add your Measurement ID
When someone books, GA4 receives calendar_event_scheduled; when the widget loads, calendar_profile_page_viewed. One tag, four cleanly named events. Readable event names like this pay off later in GA4 — they’re far easier to work with in funnel and path explorations than a single generic event you have to keep filtering on.
Capturing booking details: because the listener keeps the whole payload under calendlyPayload, you can pull specifics with Data Layer Variables when you need them — for example calendlyPayload.invitee.uri and calendlyPayload.event.uri on a successful booking. Add those as event parameters on the GA4 tag, or save them for server-side enrichment (see below).
Step 5: Test before you publish
Hit Preview in GTM, load your page, and interact with the calendar. You should see the calendly.* events appear in the Preview panel and your GA4 tag firing on each. Then check GA4 → Admin → DebugView to confirm the events arrive with the transformed calendar_* names.
One warning that trips everyone up: in Preview mode you’ll see an “Unknown domain” prompt for calendly.com offering to connect debugging to the iframe. Don’t click enable. You don’t need it — the listener catches the messages on your own domain. If you accidentally connect it, Preview starts trying to debug Calendly’s domain instead of yours; close both tabs and restart Preview to recover.
If nothing fires at all, the strict origin check is the first thing to test. Temporarily add console.log(event.origin, event.data) at the top of the listener and watch the browser console while you interact with the calendar. Confirm the origin coming through is exactly https://calendly.com — if Calendly ever serves your embed from a different origin, that guard will silently drop the messages, and you’ll know to adjust it. Remove the log once you’ve confirmed.
Once DebugView looks right, publish.
Step 6: Mark the booking as a Key Event
Of the four events, only one is the actual business goal: calendar_event_scheduled. In GA4 → Admin → Key events, add calendar_event_scheduled. This tells GA4 it’s a primary objective and surfaces it in reports like Traffic Acquisition — which is exactly where you’ll finally see which channels drive booked meetings, not just clicks. Give it 24–48 hours to populate.
Taking it server-side (why keeping the full payload matters)
Passing the whole calendlyPayload through isn’t just tidy — it’s what makes server-side enrichment possible later. On a booking, that payload carries the event and invitee URIs, and those are the hook: with them you can call Calendly’s API to pull the invitee’s details, match the booking back to a real person, and send an enriched conversion server-side to Meta CAPI, Google Ads Enhanced Conversions, or your CRM. That turns “a booking happened” into “this specific lead, from this channel, worth this much.” It’s beyond this tutorial’s scope, but because the payload’s already in the data layer, the data’s there when you’re ready to build it.
Mistakes to avoid
- Relying on Calendly’s native GA4 integration. It’s one click, but it attributes bookings to
calendly.comas a self-referral and destroys your real source data. The GTM method fires on your domain and keeps attribution intact. This is the whole reason to do it yourself. - Sending dotted event names to GA4.
calendly.event_scheduledisn’t a valid GA4 name. Transform it tocalendar_event_scheduledwith the variable in Step 3, or your events won’t land cleanly. - Forgetting to filter
page_heightat the trigger. The listener passes it through by design; the regex trigger is what keeps it out of GA4. Don’t use a plain “all custom events” trigger here or you’ll flood your reports. - Clicking “enable” on the calendly.com Preview warning. It hijacks your Preview session. Ignore it.
- Duplicate
event_type_viewed. Calendly sometimes emits this one twice, which can inflate that step of your funnel. If it becomes a problem, add a small “last event” guard in the listener to skip back-to-back repeats — or just account for it in analysis. - Assuming it works when the calendar isn’t embedded. This only works for embedded Calendly. If you redirect users to a
calendly.compage, the listener has nothing to listen to on your site. - Listener fires but no events show up. Check the origin (see testing), try firing the Custom HTML tag on Window Loaded so the widget is ready, and watch the console for errors.
- Platform quirks. Some builders (notably Wix) and some Mobile Safari sessions can be inconsistent with iframe messaging. Test on the client’s actual stack, not just a demo page.
Final thoughts
Calendly tracking is a small build with an outsized payoff: instead of every booking vanishing into a “Calendly” self-referral, you get a full booking funnel and correct channel attribution — so you can finally prove which campaigns produce real meetings. Drop in the listener, filter the noise at the trigger, convert the event name to a GA4-valid format, and keep that full payload so you’re ready to go server-side when the client wants CRM-matched, enriched conversions.