Skip to main content
Cal.com → GA4Integration guide

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

  1. 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.

  2. 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.

  3. 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.

  4. 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. 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. 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. 3

    The identifier is the bridge

    The GA4 client_id, captured at the visit and carried into the Cal.com booking.

  4. 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. 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

  1. 01

    Count bookings carrying the GA4 client_id

  2. 02

    Follow one real conversion end to end

  3. 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.

  4. 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.

  5. 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.

Get my free conversion tracking audit

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.

Get my free conversion tracking audit

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.

Free · 30 min

Get Cal.com and GA4 joined properly

Free30 minutesNo obligation