[CODE]

The click was never the booking

Replacing GA4 contact-button clicks with real Supabase booking data — navigating the gclid constraint, routing two Ads accounts through one GA4 property via distinct event names, splitting output into received vs. staying views.

7 min read AI-generated
analytics ga4 automation typescript google-ads

The direct-ads bet for Hengchun B&Bs rests on one premise: you can measure the optimization signal better than the OTA’s professional teams. OTAs dominate because they combine massive ad budgets, years of accumulated brand trust, and dedicated marketing staff operating full-time — their commission rates are higher than most small operators realize until they’ve run a full season and done the math. The case for building direct instead is not that you can outspend them. It’s that you can own a better signal, tuned to the actual economics of each property, and compound it over time. For the first several months of running direct Google Ads across two properties, the signal was pointed at the wrong thing.

Not because the tracking was broken. GA4 was capturing every on-site interaction — phone number taps, LINE clicks, the booking CTA, WhatsApp, Facebook — in real time, segmented by property via a stay_type custom parameter. The problem was structural: reservation confirmation happens by phone or messaging app, outside GA4’s visibility entirely. Contact-button clicks were the only thing that could be counted automatically, so they went into the conversion column.

Why the proxy was structurally broken

On-site contact events don’t measure what you want to optimize. They move with variables that have nothing to do with campaign quality.

Shoulder-week traffic skews toward casual browsing — those sessions generate clicks at a rate that rarely converts to an actual booking. High-priced peak dates attract more comparison shopping and more abandoned sessions. A property already near capacity doesn’t generate incremental conversions regardless of how well the ad performs. All three factors shift the click-to-booking ratio without touching the underlying campaign. The more corrosive problem: that noise correlates directly with the variables you’re trying to control when making ad decisions.

A campaign driving low-intent clicks during peak season and a campaign driving genuine leads in a quiet period can produce identical weekly click counts. Same number in the report. Structurally opposite outcomes. Optimizing against that baseline doesn’t produce random garbage — it produces garbage that confirms whatever you already believed about the campaigns.

The booking tracker

A companion article covers the data-entry design in full. The short version: the system is used primarily on iPhone by an operator whose mother is the one entering most reservations, which made the UX constraints non-negotiable. Any flow that takes more than a few taps from open to submitted doesn’t survive contact with actual daily use. The stack is Next.js 16 PWA with Supabase for auth and database, Google OAuth for shared multi-account access. PWA over native because the Apple Developer Program adds a recurring annual fee and an approval queue that turns every update into a multi-day process. Supabase and Vercel both operate on free tiers at this volume.

Each booking submission does three things in sequence: writes to the Supabase bookings table, creates a Google Calendar event in the operator’s shared “訂房日誌” calendar, then fires a GA4 Measurement Protocol event. That third step required some architecture work.

The gclid constraint

Booking-tracker entries are recorded after the fact. The guest has already called, confirmed the dates, and hung up. There is no gclid — the click identifier Google Ads uses to attribute conversions to specific ad interactions. The Google Ads Conversion API’s uploadClickConversions endpoint requires one of gclid, wbraid, or gbraid. Posting without one returns a 400 Invalid. That path was closed.

GA4 Measurement Protocol doesn’t require a gclid. It accepts an anonymous client_id, sends the booking event to the GA4 property, and Google Ads auto-syncs conversion data from there. Attribution is modeled rather than click-path matched — less precise, but usable for Smart Bidding signal.

The next constraint: both properties share one GA4 property. If both fire a generic booking_confirmed event, each Google Ads account would import the other account’s conversions. The solution is property-specific event names — each property gets its own event name, imported exclusively by its corresponding Google Ads account. Two accounts, one shared GA4 property, zero cross-contamination. The routing works because the event names don’t overlap; GA4 dispatches them cleanly.

Conversion value is calculated at submission time inside src/lib/ga4.ts: nights multiplied by the per-night rate for the booking type. The whole-property rate is roughly six times the per-room rate, so a two-night package sends the corresponding total to Smart Bidding while a single-room night sends its own rate. The click proxy couldn’t express that distinction at all — both would have looked identical as contact-button events, regardless of actual revenue.

What the pipeline sees now

The weekly analytics engine (fetch.ts in the analysis pipeline) now queries Supabase directly alongside all the GA4 Data API calls, pulling booking records and assembling them into two distinct views.

Received keys on created_at — bookings recorded during the report week, regardless of when the stay occurs. This is the funnel health indicator and the primary optimization signal. A drop in received bookings, uncorrelated with site traffic or GA4 contact events, suggests the campaign isn’t closing, not that the landing page is broken.

Staying keys on checkin_date — groups checking in during the report week. This is an operational snapshot: current occupancy, near-term cash flow. It belongs to property management, not campaign management. The response latency is different: staying tells you what’s happening now; received tells you whether the pipeline is filling.

Mixing them into a single booking total makes both questions harder to answer cleanly. A strong received week followed by a quiet staying week isn’t a funnel problem — it means forward bookings are landing on dates further out. Without the split, that pattern reads as inconsistency. It isn’t.

A third view extends the occupancy picture across three calendar months — prior, current, and next — with per-property night counts broken out by weekday versus weekend. The prior month serves as a completion baseline. The current month shows occupancy as it stands. The next month shows confirmed bookings for dates not yet arrived. An operator who can see next month’s empty weekdays can adjust pricing before the ads have to compensate.

The business-context document that the LLM uses to frame the weekly report also changed. The previous version described contact-button clicks as the headline conversion metric. The updated version sets received bookings as the primary KPI, with click events reframed as on-site intent indicators — meaningful for funnel analysis, but not campaign ROI. The same underlying data produces a qualitatively different report depending on how the context document frames the objective. Updating the data pipeline without updating the framing document would have produced a report that quotes real booking numbers while the LLM is still reasoning in click-proxy terms.

What the signal change surfaces

Before the tracker, a campaign with high click rates and low actual bookings looked healthy in every weekly report. A campaign with modest traffic and high booking conversion looked mediocre. Both reads were artifacts of the proxy — not the campaigns. The real patterns were present in the business; the measurement wasn’t pointed at them.

The bet on direct ads over OTA channels requires the optimization signal to be better than whatever you’d get by default. The OTA has professional marketing infrastructure, years of booking data, and a system that already knows how to optimize placement. The commission rate is the price of access to that machine. The case for building instead — one that only became economically viable at this client size with AI in the stack — is that once you own the signal and calibrate it to real bookings at real pricing, Smart Bidding learns something the OTA can’t see: which ad creative and keyword combination brings guests who actually confirm, and what those stays are worth per night.

The report now counts where the pipeline ends, not where it starts.