
The short answer
To track Calendly bookings in GA4, listen for Calendly's calendly.event_scheduled message on your own page, accept it only when it comes from https://calendly.com, and send one generate_lead event to GA4. Then mark generate_lead as a key event. That's the setup we use on lead-gen sites, because the conversion fires on your domain, in the same session as the ad click.
What most guides skip is what the embed actually does. We loaded Calendly's live embed on a test page and captured every message and every GA4 hit. Three things stood out:
- The embed drops click IDs.
gclid,fbclidandgbraidnever reached the Calendly iframe. Only UTMs did. - One event fires twice.
calendly.event_type_viewedarrived twice, in the same millisecond, on every run. - The sample listener in Calendly's docs can be fooled. A fake booking message from another domain produced two GA4 leads.
None of these break your setup in a way you'd notice in a day. They show up a month later as a funnel that doesn't add up and a Smart Bidding campaign optimizing on junk.
What are your options for Calendly tracking?
There are four ways to get a booking into GA4, and they aren't equal.
- Calendly's GA4 integration. You paste a Measurement ID into Calendly, and it fires events like
invitee_meeting_scheduled. Calendly's Google Analytics help page says it's one Measurement ID per organization, paid plans only, and no Google Tag Manager support. The events fire from Calendly's page, not yours, and that page also notes GA shows the source as (direct) when there are no UTM tags. Fine for a booking link you share in emails. Weak for paid traffic landing on your site. - The postMessage listener on your page. The embed tells the page it sits on about each step. You decide what to send. This is the one we recommend.
- Redirect to a thank-you page. Simple, and it plays well with any tag. It has a privacy trap we'll get to.
- Webhooks. Calendly posts
invitee.createdto your server. Great for your CRM and offline conversions, but it knows nothing about the browser session, so on its own it can't credit an ad click.
The browser listener is for GA4 and the ad pixels. The webhook is for the CRM. Most accounts that spend real money need both.
What does the Calendly embed actually send to your page?
Calendly documents five messages in its parent window events guide: profile_page_viewed, event_type_viewed, date_and_time_selected, event_scheduled and page_height. Here's what we saw on a public scheduling page, going from the profile to one event type and picking a time, without booking:
calendly.profile_page_viewed: once, when the profile loadedcalendly.event_type_viewed: twice, same millisecond, when the event page openedcalendly.date_and_time_selected: once, after we picked a time and clicked Nextcalendly.page_height: seven times, as the frame resized
All of them came from the origin https://calendly.com. The double event_type_viewed happened in three of three runs. If you built a GA4 funnel from these messages and your tag fires on each one, the "viewed event type" step is inflated by 100%, and your drop-off from there to booked looks twice as bad as it is.
The booking message itself carries almost nothing:
{
"event": "calendly.event_scheduled",
"payload": {
"event": { "uri": "https://api.calendly.com/scheduled_events/ABCD..." },
"invitee": { "uri": "https://api.calendly.com/scheduled_events/ABCD.../invitees/EFGH..." }
}
}
No name, no email, no event type name. That's good for privacy, and it means the invitee URI is your join key. Hold on to it.
Why your ad click IDs disappear
When Calendly's widget.js builds the iframe, it copies parameters from your page URL. We read the shipped script and then confirmed it with a live page loaded with ?utm_source=google&utm_medium=cpc&utm_campaign=audit_q4&gclid=TESTGCLID123&fbclid=TESTFBCLID456&gbraid=TESTGBRAID. The iframe URL came out as:
https://calendly.com/<page>?embed_domain=...&embed_type=Inline
&utm_source=google&utm_medium=cpc&utm_campaign=audit_q4
The script has a fixed allowlist: the five utm_ fields plus salesforce_uuid. Everything else is dropped. The API's tracking object, which is what your webhook gets, has the same six fields and nothing more.
So if a booking only lives in Calendly, the Google Ads click that paid for it is gone. GA4 on your own page still has the session, which is one more reason to fire the conversion there. For offline conversion uploads, you need to keep the click ID yourself.
There's a clean way to do that. The embed's utm option accepts a salesforceUuid, and the script passes it through. We tested it with a random visit ID and it appeared in the iframe URL as salesforce_uuid=v_gj3l5zfp. If you don't use Salesforce, that field is a ready-made slot for your own visit ID, and the API schema says it comes back in the tracking object. We didn't book a real meeting on someone else's calendar to see it land in a webhook, so test it on your own account before you rely on it.
The listener we use
This is the version we put on client sites. It checks the origin, only reacts to the booking, skips repeats, and sends the click IDs and invitee URI to your backend:
<script>
window.addEventListener("message", function (e) {
if (e.origin !== "https://calendly.com") return;
if (!e.data || e.data.event !== "calendly.event_scheduled") return;
var inviteeUri = e.data.payload && e.data.payload.invitee && e.data.payload.invitee.uri;
if (!inviteeUri) return;
var key = "cal_booked_" + inviteeUri.split("/").pop();
if (localStorage.getItem(key)) return;
localStorage.setItem(key, "1");
gtag("event", "generate_lead", { method: "calendly" });
var p = new URLSearchParams(location.search);
navigator.sendBeacon("/api/calendly-booking", JSON.stringify({
invitee_uri: inviteeUri,
gclid: p.get("gclid"),
fbclid: p.get("fbclid"),
gbraid: p.get("gbraid"),
wbraid: p.get("wbraid")
}));
});
</script>
Two notes. Read the click IDs from wherever you already store them on landing (a first-party cookie is better than the current URL, since visitors click around before they book). And if you're on GTM, push a dataLayer event from the listener instead of calling gtag directly. The logic is the same.
Can someone fake a Calendly booking?
Yes. Calendly's sample listener checks only that the event name starts with calendly. Any frame on your page can call parent.postMessage(), and so can a page that opens yours in a popup.
We tested it. Our test page had the real Calendly embed plus a small iframe from a different domain, like an ad slot or a chat widget. That iframe posted a fake calendly.event_scheduled message twice. With the docs-style listener, GA4 got two generate_lead hits from a booking that never happened. With the origin check above, it got zero, and the real Calendly messages still came through.
Is anyone going to attack your lead count on purpose? Probably not. But third-party scripts on landing pages are messy, and a tag that trusts any message with the right name is how you end up with conversions that don't match the calendar. A one-line origin check costs nothing.
What's wrong with the thank-you page redirect?
Calendly can send invitees to your own page after they book. If you also tick "Pass event details," its redirect help page lists what can go in the URL: invitee_email, invitee_full_name, invitee_first_name, invitee_last_name, text_reminder_number, and answer_1 through answer_10.
GA4 records the full page URL by default. So a thank-you page that loads GA4 with those parameters in the URL sends names, emails and possibly phone numbers to Google, which Google's analytics policy doesn't allow. We've seen this in audits more than once, usually on a site where someone turned on the option for a CRM form and forgot about it.
If you use the redirect, either leave "Pass event details" off, or read the values you need and clean the URL with history.replaceState before the GA4 config tag runs. And remember the redirect page counts a conversion every time it loads, including refreshes and bookmarks. Guard it the same way, with the invitee_uuid from the URL.
Getting the conversion into Google Ads and Meta
Once generate_lead is a key event in GA4, you can import it into Google Ads. That's the quick path. For accounts where the meeting is the real conversion and the sale happens later, we go further:
- The listener sends the invitee URI and click IDs to a small endpoint (on our side it's usually an n8n webhook).
- The
invitee.createdwebhook arrives with the email, the answers and the UTMs. - The two are matched by invitee URI, and the lead is saved with its
gclid. - When the deal closes, the sale goes back to Google Ads as an offline conversion, and to Meta through the Conversions API.
Step 2 also lets you send hashed email to Google, which is what enhanced conversions need to match. And since Safari and iOS increasingly strip click IDs from links, the email match is your backup when gclid is missing, as we cover in our iOS 26 click ID guide.
One more thing to check: the Calendly embed shows its own cookie banner inside the frame. It doesn't replace yours, and if your consent setup holds GA4 back until the visitor accepts, a booking from someone who never answered your banner won't be counted. That's expected behavior, not a bug. Our post on consent mode and missing conversions explains how to tell the two apart.
A ten-minute check on your own site
- Open your booking page with
?utm_source=test&gclid=TEST123on the URL. - In DevTools, inspect the Calendly iframe
src. You'll seeutm_source=testand nogclid. That's normal. Confirm your own code savedTEST123somewhere. - In the Console, run
window.addEventListener("message", e => console.log(e.origin, e.data))and click through. Count how manyevent_type_viewedlines you get. - Book a test meeting on your own calendar. Filter the Network tab for
collectand confirm exactly onegenerate_lead. - Reload the page. No second
generate_leadshould fire. - If you use the redirect, look at the thank-you page URL. If you can read an email in it, fix that today.
How we tested this
On November 11, 2026 we served a local test page and loaded it in headless Chromium 148 with Playwright 1.60. The page embedded Calendly's current widget.js (inline embed) pointing at a public Calendly scheduling page, loaded GA4's gtag.js with a dummy Measurement ID, and included a cross-origin iframe that posted a fake booking message. Every GA4 hit was intercepted and answered locally, so nothing reached Google. We clicked through to one event type, picked a date and time, and stopped before booking. We ran the docs-style listener once and the origin-checked listener twice. The parameter allowlist was read from the shipped widget.js, and the tracking fields from Calendly's published OpenAPI file. We didn't complete a real booking, so the event_scheduled behavior described here comes from Calendly's documentation plus our spoofed message, not a live booking.
Get a Free Automation Audit
If your calendar is full but your ad platforms say leads are down, the problem is usually the tracking between the booking widget and the ad account. Get a Free Automation Audit and we'll email you an initial audit report within 24 hours covering your booking tracking, click ID storage and conversion imports. If you'd rather we just build it, see how we wire bookings into CRMs and ad platforms with n8n workflow automation.
