An agency's hand-filled lead sheet, automated: 61% of calls matched to Google Ads

Three n8n milestones for a US GoHighLevel agency: nightly call attribution, a loyalty-member import that can safely run twice, and a chatbot warehouse. 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%.

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.

Recreated with sample data. Waiting time is sped up.
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.

  1. The Google Ads side, pulled every night
  2. 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

  1. 02:00 client time: pull Google Ads calls

    GAQL, ad group included

  2. Normalise and remove duplicates

    the sheet's exact format

  3. Append to this month's tab

  4. 03:00: pull GoHighLevel calls

    date-cursor pagination

  5. Match GHL calls to Ads rows

    timestamp and duration windows, deterministic tie-breaks

  6. Write full number, match status and message ID

  7. Append calls that only exist in GoHighLevel

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