Google Ads will tell you a caller's area code and nothing more. So when this agency's client wanted to know which ads produced real phone calls, someone updated a monthly sheet by hand and still could not answer the question.
- of one month's calls matched to Google Ads, up from 20% on the first run, in a different month, before my fixes
- 61%
- of that month's calls with the transcript attached
- 86%
- duplicates when the loyalty import re-runs on the same file
- 0
From the agency's specs, the handover log and a one-month export, October 2025 to May 2026. The 20% figure is the first run before my fixes and covers a different month than the 61%.
US marketing agency running GoHighLevel for nonprofit and local-business clients, Oct 2025 to May 2026
Watch it run.
Read the narration
Google Ads gives only a caller's area code. So this agency's client filled a monthly lead sheet by hand, and still could not tie an ad to a call.
At two every night, client time, n8n pulls the Google Ads calls with GAQL and appends them to this month's tab. At three, it pulls the GoHighLevel calls.
Each GoHighLevel call is matched to an Ads row on timestamp and duration windows. A match writes the full number, the match status and the message ID.
Calls that exist only in GoHighLevel are appended, and transcripts arrive through a webhook.
Writes are keyed on message IDs, so a re-run never duplicates a call, and nothing is written to the ad account.
In one month's export, 61 percent of calls matched a Google Ads call. The first run, before the fixes and in a different month, matched 20 percent.
Before handover, a row-number backfill was rebuilt on business keys, and a sheet-corrupting update step was removed.
At handover, six workflows were live, and the lead sheet filled itself every night.
Before the build.
Call attribution. A home-services advertiser's monthly lead sheet was updated by hand with Google Ads calls and GoHighLevel form fills, and no ad could be tied to a real lead, recording or transcript.
A half-finished sync. Two n8n workflows for a nonprofit retailer's loyalty program, left about 90% done and switched off by a previous developer. The third milestone, a chatbot nobody could read, is below.
The constraint the whole build had to solve
Google Ads only returns a caller's area code, never the full number.
It is why the match is made on timestamp and duration windows rather than on a phone number.
What I built.
Two nightly workflows pull both sides of the same month and match them on timestamp and duration, with deterministic tie-breaks.
The spine is call attribution, in two halves. The other two milestones have their own chapters below.
- The Google Ads side, pulled every night
- Matching a call to the ad that paid for it
What is true today.
- In one month's export, 61% of calls matched a Google Ads call, 28% existed only in GoHighLevel, and 86% had a transcript attached. The first run, before my fixes, had matched 20%.
- At handover, the monthly lead sheet filled itself every night instead of by hand.
- The loyalty contact import was delivered with its lookup chain, and re-runs create zero duplicates.
- The chatbot's conversations and messages load into Supabase for reporting, with SQL checks that prove a re-run does not duplicate them.
Still open
- Per the spec, the chatbot's daily sync was handed over switched off, for the agency to turn on when it is ready.
Updated
The details, when you need them.
The Google Ads side, pulled every night
A daily workflow pulls the calls with GAQL, ad group included, and a separate workflow creates each month's tab so nobody has to remember to open one.
Matching a call to the ad that paid for it
A second workflow pulls GoHighLevel calls, paging by date cursor because the endpoint ignores page and offset, and matches them to the Ads rows on timestamp and duration windows. It writes the full phone number, the match status and the message ID.
Transcripts arrive through a real-time webhook, with a rate-limited backfill for the history.
- Nightly, 02:00 and 03:00 client time
- Runs in the agency's own n8n
- Writes to the sheet, never to the ad account
ads-call-attribution
02:00 client time: pull Google Ads calls
GAQL, ad group included
Normalise and remove duplicates
the sheet's exact format
Append to this month's tab
03:00: pull GoHighLevel calls
date-cursor pagination
Match GHL calls to Ads rows
timestamp and duration windows, deterministic tie-breaks
Write full number, match status and message ID
Append calls that only exist in GoHighLevel
Attach transcripts
real-time webhook, rate-limited backfill
6 workflows active at handover / verified end to end
A sync that can safely run twice
Loyalty members, arriving as daily CSV files, into GoHighLevel without creating a single duplicate.
I finished the contact import: email and phone normalisation, a three-step lookup with no-match logging, custom-field mapping pulled live from GoHighLevel so nobody maintains field IDs by hand, and invalid rows that skip and log instead of breaking the run.
The property that mattered to the agency was not speed. It was that somebody could re-run yesterday's file by accident and nothing bad would happen.
- duplicates created when the import runs again on the same file
- 0
- lookups per contact, in order: customer ID, then phone, then email
- 3
Giving an agency eyes on its own chatbot
The nonprofit's GoHighLevel AI was answering loyalty members by SMS, and nobody could read a word of it.
The workflow pulls conversations since the last sync, fetches messages at a controlled rate, labels bot versus customer, and writes them to Supabase with unique IDs so re-runs never duplicate.
It shipped with SQL acceptance checks for the bot labels, for completeness, and for re-run dedup, so the agency can prove the warehouse is right rather than trust that it is.
- nodes in the sync workflow
- 17
- hard timeout on any single run
- 30 min
What happens when it fails?
- A history backfill that matched on row numbers failed, so I rebuilt it to match on business keys. It finished in one clean run, and I then switched it off so it cannot run twice.
- An update step that was corrupting the sheet's structure was removed. Calls that only exist in GoHighLevel are appended plainly instead.
- Every schedule and every date calculation is pinned to the client's time zone.
- Verified end to end with cloned webhook runs before handover, and the clones were deleted afterwards.
- Idempotent writes throughout: message IDs on calls, unique IDs on chatbot messages, a lookup chain on contacts.
What I could not show you
- The call data drawn on this page is a count, not a record. No phone number, caller or transcript appears here.
- The agency's end clients are described by sector only, never named.
- The two matching runs cover different months, so the 20% and the 61% are not a controlled before and after.
Measurements and project details
- n8n workflows live at handover
- 6
- Client
- US marketing agency running GoHighLevel for nonprofit and local-business clients
- Industry
- Marketing agency
- Region
- United States
- Timeline
- Oct 2025 to May 2026
- Engagement
- Fixed-price milestones
- Role
- n8n automation partner
Stack
- n8n
- GoHighLevel API
- Google Ads API (GAQL)
- Google Sheets
- Supabase
- SFTP
- JavaScript