Connecting Cal.com to GA4
Cal.com holds the booking and, unusually, will let you self-host and query the database directly. GA4 is where the business goes to understand what happened, which makes an unattributed conversion there expensive in decisions rather than in ad spend. The join between them succeeds or fails on one thing: whether the GA4 client_id reaches the Cal.com booking before the conversion happens, because GA4 can only match a conversion it can tie back to a click it recognizes.
Free30 minutesNo obligation
Specific to this pair
Cal.com to GA4: carry client_id through the embed and routing form
Read client_id and session_id on your page and pass them into hidden Cal.com booking questions with prefill parameters, including through routing forms. Then send BOOKING_CREATED through Measurement Protocol as generate_lead or book_appointment, with event type slug, duration and any paid amount. Use one sender, not a Cal.com analytics app too, and send cancellations and reschedules as filterable custom events.
When Cal.com is embedded on your site, the booking runs in an iframe on Cal.com's origin, so the _ga cookie GA4 reads on your page is not available where the booking happens. The fix is specific to Cal.com: read client_id and session_id on your page and pass them into hidden booking questions using Cal.com's prefill query parameters, including on any routing form in front of the event type.
Then send from the BOOKING_CREATED webhook as generate_lead (or a custom book_appointment event) through Measurement Protocol, with the event type slug, duration and, for paid event types, the amount and currency charged. The webhook fires within seconds, so you are far inside the 72-hour backdating limit and the session_id still points at a live session, which is what keeps the booking credited to its source instead of (direct)/(none).
Pick one sender. Cal.com's app store offers browser-side analytics apps per event type; if one is enabled and also fires a booking event into the same GA4 property, every booking counts twice, because Measurement Protocol events are not deduplicated. BOOKING_CANCELLED and BOOKING_RESCHEDULED should not create new key events; send them as custom events you can filter out. On self-hosted Cal.com, query recent bookings in the database for the client_id response to measure coverage before trusting any GA4 figure.
Cal.com → GA4 · what breaks
Why this join usually fails — Cal.com → GA4
- 01
The GA4 client_id never reaches Cal.com
As with any hosted booking page, the identifier must be passed on the booking URL and captured into a booking question. Cal.com's routing forms add a second surface that also needs to carry it, and they are commonly missed.
- 02
Routing forms are a second capture surface
Cal.com routing forms sit in front of the booking and are easy to forget. An identifier captured on the booking page but not on the routing form leaves a hole in exactly the traffic that used routing.
- 03
Everything lands under (direct)/(none)
The classic Measurement Protocol symptom, and it always means the same thing: the client_id was missing or wrong, so GA4 had no session to attach the event to and invented a new user.
- 04
Nothing reconciles afterwards
GA4 DebugView shows events arriving with their parameters, and Realtime confirms the user and session they attached to. Neither tells you whether you are double-counting — for that, compare GA4's conversion count against the source system's record for the same window.
Cal.com → GA4 · the fix
What working looks like — Cal.com → GA4
The GA4 client_id on every booking
Conversions arriving in GA4
Conversions appear in GA4 attributed to the original session's source and medium, so acquisition reporting reflects what actually drove revenue.
Reconciled against the source
Cal.com → GA4 · architecture
How Cal.com and GA4 actually connect — Cal.com → GA4
- 1
Cal.com holds the truth
The booking carries the attendee, the event type, the time and any custom booking-question responses. Self-hosted instances give you direct database access, which makes reconciliation genuinely easy compared with every other booking tool.
- 2
GA4 needs a click to match
GA4 needs the client_id to attach a server event to the user and session that produced the click. Without it there is no attribution to inherit and the event is orphaned.
- 3
The identifier is the bridge
The GA4 client_id, captured at the visit and carried into the Cal.com booking.
- 4
The event source decides reliability
Send from your server on the webhook, and handle BOOKING_CANCELLED for reversals. Self-hosted instances can read from the database instead, which is the most reliable option available on any booking platform.
- 5
Deduplication at the destination
GA4 has no built-in deduplication for Measurement Protocol. If the browser also sends the event, it counts twice. The only reliable approach is to decide per event which side sends it, and enforce that decision — GA4 will not protect you from your own duplicates.
Cal.com → GA4 · normal or broken
Working vs. broken, side by side — Cal.com → GA4
Normal, not a fault
The GA4 client_id present on the booking
Sent from the BOOKING_CREATED webhook
Deduplicated correctly
GA4 has no built-in deduplication for Measurement Protocol. If the browser also sends the event, it counts twice. The only reliable approach is to decide per event which side sends it, and enforce that decision — GA4 will not protect you from your own duplicates.
Reconciles with the source
Actually broken
The GA4 client_id missing on most records
As with any hosted booking page, the identifier must be passed on the booking URL and captured into a booking question. Cal.com's routing forms add a second surface that also needs to carry it, and they are commonly missed.
Sent from the browser
Cal.com routing forms sit in front of the booking and are easy to forget. An identifier captured on the booking page but not on the routing form leaves a hole in exactly the traffic that used routing.
Counted twice, or not at all
The classic Measurement Protocol symptom, and it always means the same thing: the client_id was missing or wrong, so GA4 had no session to attach the event to and invented a new user.
Nobody has reconciled it
Cal.com → GA4 · diagnose
Diagnostic order — Cal.com → GA4
- 01
Count bookings carrying the GA4 client_id
- 02
Follow one real conversion end to end
- 03
Query recent bookings for the identifier field
Direct database query on self-hosted, API on cloud. Then repeat for bookings that came through a routing form specifically.
- 04
Check whether server events carry a session and a source
In GA4 Realtime or DebugView, find the event and look at its session and traffic source. A brand-new user with no source on every server event is the missing-client_id signature.
- 05
Read the match rate, not the success message
GA4 DebugView shows events arriving with their parameters, and Realtime confirms the user and session they attached to. Neither tells you whether you are double-counting — for that, compare GA4's conversion count against the source system's record for the same window.
Cal.com → GA4 · prove
How it gets proven — Cal.com → GA4
Source of truth
Cal.com's bookings list for the same window, with cancellations separated.
- The GA4 client_id confirmed present on a live booking.
- Server events confirmed in DebugView attaching to an existing session with the original traffic source intact.
- Coverage confirmed separately for direct bookings and for routing-form bookings.
Cal.com → GA4 · free audit
What we check in the free audit
- Count bookings carrying the GA4 client_id
- Follow one real conversion end to end
- Query recent bookings for the identifier field
- Check whether server events carry a session and a source
- Read the match rate, not the success message
Checked against
Cal.com's bookings list for the same window, with cancellations separated.
Free30 minutesNo obligation
Cal.com → GA4 · when not to buy
When you don't need this — Cal.com → GA4
- Measure identifier coverage yourself first — pull your last hundred bookings and count how many carry the GA4 client_id.
- Self-hosting Cal.com already gives you the data. If you have engineering capacity in-house, this is one of the few integrations a competent developer can genuinely do alone.
- GA4 is reporting, not bidding. If your problem is ad platform optimization, fix the ad platform destination first — GA4 will not change what any campaign does.
Cal.com → GA4 · what we deliver
What gets built — Cal.com → GA4
Identifier capture into Cal.com
Add a hidden booking question, append the identifier via Cal.com's prefill query parameters, and verify through the API or directly in the database on a self-hosted instance.
Event source: the BOOKING_CREATED webhook
Send from your server on the webhook, and handle BOOKING_CANCELLED for reversals. Self-hosted instances can read from the database instead, which is the most reliable option available on any booking platform.
Dispatch to GA4
Measurement Protocol events with the client_id, session_id and the correct measurement ID and API secret, sent server-side.
Reversals where they exist
BOOKING_CANCELLED and BOOKING_REJECTED both fire with the reference, so reversals are straightforward.
Written guarantee
95%+ tracking accuracy
Measured against Cal.com's bookings list for the same window, with cancellations separated.. Miss it and we keep working at no cost.
From $400 · See packages
FAQ
Cal.com → GA4 questions, answered
The event was sent without the visitor's client_id, usually because the embed or routing form never received it through a prefill parameter. GA4 then creates a new user with no source.
It runs browser-side on the booking page, so it misses blocked and non-consented visitors. Check what it fires, and do not run it alongside a server-side send.
Almost always because the GA4 client_id never reached the Cal.com booking. As with any hosted booking page, the identifier must be passed on the booking URL and captured into a booking question. Cal.com's routing forms add a second surface that also needs to carry it, and they are commonly missed. Measure coverage before debugging the dispatch.
The client_id, from the _ga cookie. Measurement Protocol events without it are rejected or create a brand-new user with no session and no source, which is how server events end up filed under (direct)/(none) and make paid channels look worse than they are. Send session_id alongside it so the event joins the existing session, and include engagement_time_msec or the event may not appear as expected in reports.
The BOOKING_CREATED webhook. Send from your server on the webhook, and handle BOOKING_CANCELLED for reversals. Self-hosted instances can read from the database instead, which is the most reliable option available on any booking platform.
GA4 DebugView shows events arriving with their parameters, and Realtime confirms the user and session they attached to. Neither tells you whether you are double-counting — for that, compare GA4's conversion count against the source system's record for the same window.
More on Cal.com → GA4
- Cal.com + GoogleGoogle Ads and GA4 tracking for Cal.com
- Cal.com → Google AdsConnecting Cal.com to Google Ads
- Cal.com → MetaConnecting Cal.com to Meta Conversions API
RELATED GUIDES
Where Cal.com → GA4 fits in the rest of your tracking
- Acuity → GA4Connecting Acuity Scheduling to GA4
- SimplyBook.me → GA4Connecting SimplyBook.me to GA4
- Square Appointments → GA4Connecting Square Appointments to GA4
- Cal.com → AmplitudeConnecting Cal.com to Amplitude
- Cal.com → PostHogConnecting Cal.com to PostHog
- Coaches & consultantsConversion tracking for coaches and consultants, where every booked call counts
- Setmore → GA4Connecting Setmore to GA4
- YouCanBook.me → GA4Connecting YouCanBook.me to GA4
Free · 30 min
Get Cal.com and GA4 joined properly
Free30 minutesNo obligation
