SYSTEM DEBRIEF

Lead source tracking that decides routing: gclid, call tags and live chat

Debrief: 13
A South America travel company sends Google Ads enquiries to one partner and everything else by destination. How the n8n router detects a paid click, routes phone calls on CallTrackingMetrics tags, stamps which system wrote each row, and why live chat still can't be attributed.

A South America travel company routes every traveller enquiry to one of several partner tour operators. Most of the routing depends on the destination. One rule depends on the source: an enquiry that came from a Google Ads click goes to the largest partner, whatever the destination.

That one rule changes what attribution means. In most businesses, getting the source wrong produces a bad report. Here it sends a live traveller to the wrong company. Since 28 September 2026 the routing has run in an n8n engine, and this is the record of how it decides where an enquiry came from across three channels, what it still can’t see, and the two places where caller data lied.

Three channels, three kinds of source signal:

  • web forms: click IDs and UTM parameters;
  • phone calls: call-tracking tags;
  • live chat: nothing usable, which is a finding in itself.

Stack, as of September 2026: n8n Cloud, Gravity Forms 3.1 on WordPress, CallTrackingMetrics (REST API v1), Tawk.to with its AI Assist agent, Google Sheets, and Salesforce REST API v62.0.

Why does a lead’s source decide where it goes?

The routing is destination × traffic source. The order matters:

  1. A trip that includes the Guianas stays in-house; no partner sells them.
  2. Then any Google Ads click goes to the largest partner.
  3. Then destination decides.

Paid leads carry one reason code, paid, because the ad click is decided before any destination is looked at. So the same Peru enquiry goes to the partner that handles Peru when it arrives organically, and to the largest partner when it arrives from an ad.

The old workflows computed “paid” in several places with several different tests. Now one node does it, once.

How does the router decide a web enquiry came from an ad?

gclid is the click ID Google Ads adds to the landing URL when auto-tagging is on. On some privacy-restricted traffic, notably iOS app-to-web, Google sends gbraid or wbraid instead.

The definition lives in one n8n Set node, Resolve Paid:

={{ !!($json.lead.gclid || $json.lead.gbraid || $json.lead.wbraid || ['cpc','ppc','paid'].includes(($json.lead.utm_medium || '').toLowerCase())) }}

It’s deliberately narrow: Google Ads, the way the old system counted it. Widening it moves leads from one partner to another, which is a commercial decision, not a technical one.

One honest imprecision: the utm_medium clause ignores the source, so a Bing or Meta ad tagged cpc would count as paid too. That’s fine only while Google Ads is the only paid channel, which it was when I wrote this.

“Organic” means “no paid signal”, nothing more. Direct visits, referrals, email and chat all land there. fbclid and msclkid are captured and kept, but they don’t count.

Each form stores its click IDs and UTMs on different field IDs, so every form is read through its own field map. The rule never sees a raw form.

How do you know why a lead was marked paid?

Resolve Paid also records which signal fired first, as lead.paidSignal: gclid, gbraid, wbraid, utm_medium=<value>, or empty.

={{ ((l) => l.gclid ? 'gclid' : (l.gbraid ? 'gbraid' : (l.wbraid ? 'wbraid' : (['cpc','ppc','paid'].includes(String(l.utm_medium || '').toLowerCase()) ? 'utm_medium=' + String(l.utm_medium).toLowerCase() : ''))))($json.lead) }}

The delivery log stores paid, paid_signal and reason on every lead. A synthetic paid test lead logged paid=TRUE, paid_signal=gclid, reason=paid.

That column exists for a reason. URL attribution can be hand-typed: anyone can add ?gclid= to a link. The stake is which partner receives the enquiry, so I chose to make every paid decision visible after the fact rather than try to validate click IDs.

Which ad clicks can’t the router see yet?

The gbraid and wbraid branches exist in the rule, but nothing captures those values yet. The site’s attribution script doesn’t record them, the forms have no fields for them, and only one of the engine’s form normalizers reads them at all.

So a paid click that arrives with gbraid, no gclid and no paid medium resolves as organic. A paid Peru enquiry from an iPhone goes to the Peru partner instead of the largest one.

The dead branches stay on purpose. The rule is already correct for the day the capture lands.

How does a phone call get a source?

CallTrackingMetrics gives each marketing source its own tracking number, routes calls, tags them, and can send a webhook per call. Tags are set by tracking number or call route, so the phone split lives in the call’s tags, not in a destination.

Calls run through their own n8n workflow. The web engine’s validity gate requires an email address, and a phone call doesn’t come with one.

n8n workflow canvas for phone-call routing, 15 nodes from the call webhook to the partner sheet, Salesforce and quarantine.
The phone-call workflow in n8n: 15 nodes, from the call webhook through routing to the partner sheet, Salesforce and quarantine.

The rule, in a Code node named Route Call:

const TAGMAP = { '<partner-b>': 'partner_b', '<partner-c>': 'partner_c',
                 '<initials-c>': 'partner_c', '<largest>': 'largest' };
const lower = call.tags.map((t) => String(t).toLowerCase());
let partner = '', reason = '';
for (const t of lower) for (const k of Object.keys(TAGMAP))
  if (t.indexOf(k) !== -1 && !partner) { partner = TAGMAP[k]; reason = 'tag:' + k; }
if (!partner) { partner = 'largest'; reason = 'default-no-partner-tag'; }

That’s simplified from the real node, with partner names replaced. It lower-cases the tags, substring-matches them against a small map, and the first match in tag order wins. A call with no partner tag goes to the default partner, logged as default-no-partner-tag, so the default is a recorded decision rather than an accident. A call that matches two partners is flagged ambiguous. A voicemail, or CTM’s own spam tag, sets needsReview, but the call is still delivered, because the client wants every caller called back.

Why substring and not exact match: real tags mix partner keys with free-form notes, like <partner> vm, <partner> queue, <agent> <partner> and call for <agent>. An exact list misses most of them.

Only inbound calls are routed. Outbound calls through the account are quarantined as not-inbound; two of the first five real calls through the new workflow were outbound.

The webhook fires on CallTrackingMetrics’ end event, “once all data has been captured”. The update event fires repeatedly with nothing to de-duplicate it, and end_immediate fires before the transcript and the tags attach. Tags are the routing key.

CallTrackingMetrics also reports paid_source and paid_medium per call. Those are logged, in the paid-signal column and in the CRM message, but phone routing doesn’t use them.

The old router got this wrong in a way worth knowing. Its default route matched every inbound call, and a Pabbly Router runs every route whose conditions are true, so a call tagged for one partner also went to the default partner. Roughly a third of runs in its retained history delivered a call twice. Now each call goes to exactly one partner.

How did one partner’s client end up with a competitor?

I didn’t find this one with test calls. I pulled the account’s full tag list and a few hundred recent calls through CallTrackingMetrics’ read-only API, in two requests. Five hand-made test calls would never have shown it.

Of the account’s 221 tags, one partner tag named no partner at all: an agent’s first name plus the partner’s initials. It was that agent’s direct line. The call transcripts had the agent answering with the partner’s name, and the call path rang that agent.

Eight inbound calls carried that tag. With no partner name to match, they fell to default-no-partner-tag and went to the largest partner. So a traveller already working with one partner, calling their own agent, was handed to a competing partner. That’s worse than a duplicate. It’s a leak.

It predated the rebuild, because the old filters looked for the partner’s name too. My first map was keyed on partner names as well, and would have carried the leak straight across. The fix was to add the initials as a key, with a test asserting that string appears in exactly one tag. It was fixed on 25 September, before the phone path went live.

Some tags are deliberately unmapped. Former partners’ tags fall to the default on purpose. Destination tags need no rule, because every call carrying one also carried the partner tag that would pick it up.

And the limit, as first published: the map was checked against a snapshot of the tag list, so a new agent line whose tag named no partner would have fallen to the default unnoticed, which is the leak’s exact shape. Since 6 October 2026 an hourly check compares the live tag list with the reviewed one and flags any new tag, with where its calls will go, until someone decides.

Why was a caller named after a town?

CNAM is the carrier’s caller-ID name. When a line has no subscriber name, some carriers return a locality instead, padded into a CITY ST format.

The old workflow used the second word of the CNAM as the Lead’s last name. A one-word or empty CNAM therefore produced:

Failed (400)  REQUIRED_FIELD_MISSING
Required fields are missing: [LastName]

About a third of the old phone runs ended partial. The one I opened failed on exactly that.

My fix made the last name impossible to leave empty: the rest of the CNAM, else the formatted phone number, else Phone caller. Then the first real payload arrived, on 25 September, with a town and state in the CNAM and the same city and state in the payload’s own fields beside it. My fix split it into a first name and a last name, and delivered a town as a person. I’d taken the old root cause and given it the opposite symptom. It was caught in shadow, before any partner saw it.

The fix that stuck:

const stateCode = str('state');
const cnamIsLocality = parts.length > 1 && stateCode.length === 2
  && parts[parts.length - 1].toLowerCase() === stateCode.toLowerCase();
const nameResolved = parts.length > 0 && !cnamIsLocality;

When the last CNAM token equals the payload’s own two-letter state, the router claims no name. The last name falls back to the formatted number, and the call is marked for review. The state field has to agree, so a surname alone can’t trigger it. It’s tested both ways.

It wasn’t an edge case. It was three of the first five real calls, and 40% of inbound calls in the API sample, as of 25 September 2026. Only one of those first five carried a real person’s name, and that one was outbound.

Nothing is lost by refusing the name: the raw caller-ID text stays in the partner’s sheet and in the CRM message.

Two residuals are accepted. A one-word CNAM that isn’t a person, such as a telecom platform’s name, still reads as a surname. And a real two-letter surname that matches the caller’s state reads as a town; it’s flagged, and the raw text is kept.

Why can’t a live chat be attributed?

Before the rebuild, chats arrived as an emailed transcript. The campaign data never made the trip, and every chat went to the largest partner, unrouted.

My hypothesis, written on 31 August: Tawk.to holds the campaign data, so a webhook would make chats routable on paid versus organic.

The webhook payload, on 22 September, had no visitor.email, no page URL (domain was the bare host and referrer was external), and no tags or attributes. visitor.name was the visitor ID. Everything useful was in message text written by an AI agent whose prompt anyone with dashboard access can edit. That’s not something to parse.

So I went another way. Tawk’s AI Assist agent can call a custom tool described in OpenAPI (3.0.3 here), so the agent calls us with typed fields: email, name, phone, phone country, destinations, travel date and number of travellers. A small adapter workflow reshapes that call for the engine, which routes it on the destination the traveller gave.

One snag worth recording: Tawk preflights the schema URL with a HEAD request. n8n answers an unhandled method with a 404, which Tawk reports as “invalid schema file url”. Adding a HEAD responder fixed the 404 but not the validation, and pasting the schema in directly worked.

The verdict on attribution is still no. A test chat’s record in Tawk showed every UTM, the gclid and a campaign tag. The agent can’t read any of it. Asked, it said: “I don’t have access to URL parameters or chat tags…”. I kept the probe fields in the tool, worded so the agent leaves them empty rather than inventing values, because they cost nothing. They come back empty.

So chats resolve organic. The only chats that land differently than they should are paid chats whose destination the organic rule sends elsewhere, which is still better than no routing at all. The remaining options are Tawk’s access-gated REST API, joining on the chat ID, or a pre-chat form.

One more piece: the agent decides when to call the tool. That’s judgment, not a fixed trigger. So a second workflow receives the transcript webhook for every chat and delivers nothing, and a chat the agent never handed over stays visible in n8n’s run history.

Which system wrote this row?

The partners read their leads in Google Sheets. The old workflows wrote those sheets for years, and the engine writes into the same tabs, not copies.

So every row the engine writes, web and phone, carries a Pipeline column set to n8n. Every existing row was backfilled as Pabbly. The column sits after the last named column, so nothing shifts.

It tells the two systems apart during a cutover and after it, and it shows a delivery gap at a glance.

Build notes:

  • 16 live tabs across three spreadsheets.
  • The phone Calls tabs are 94 columns wide, with the last header at the edge of the grid, so widen the grid before writing the header, or Sheets rejects the range.
  • The backfill only fills blank cells on rows that have content, so it can be re-run safely. It was re-run at cutover.
  • The workflow simulators assert the stamp is present and last on every row shape.

After cutover it became a check: a blank Pipeline cell means an old path is still writing. The pipeline monitor counts them. On day one it read zero.

What’s live, and what still isn’t captured?

Live since 28 September 2026: the paid override on every web form, phone calls routed to one partner by tag, chats routed on destination, and every row stamped. The first real calls after cutover arrived with Salesforce Leads and real caller names.

Not captured, as of October 2026:

  • gbraid and wbraid: not in the attribution script, the form fields, or most normalizers.
  • Anything beyond last touch. First-click, IP, browser, operating system, city and country all stay empty.
  • Country of origin. A lookup at page render would stamp the first visitor’s country onto every edge-cached page, so it needs a client-side lookup, which is planned. Until then, the partners’ contact and newsletter tabs lost the country and city columns the old system used to fill from its own IP lookup.
  • Live chat source, as above.

And texts. CallTrackingMetrics’ webhook doesn’t fire for SMS, so text messages, about 14% of the account’s activity, never reach the router, and nothing routes them. I’d predicted they would pile up in quarantine. A real text on cutover day left no execution at all. Whether the old system ever routed them, I don’t know.

What would I check first next time?

  • Define “paid” in writing, keep it narrow, and treat widening it as a commercial decision.
  • Log the signal behind each routing decision, not only the decision.
  • Pull the real tag list before writing a tag map, match substrings, record the default as a decision, and hunt for tags that name a person instead of a partner.
  • Never take a caller-ID name as a person’s name without checking it against the call’s own location fields.
  • Name the channels you can’t attribute, instead of letting them default quietly to “organic”.
  • When two systems write the same output, stamp every row with its writer.

Part of a series. Six System Debriefs on rebuilding the lead routing of a South America travel company. This is part 4.

  1. From 51 Pabbly Connect workflows to three in n8n — the routing rule these source signals feed, and the rebuild it came from.
  2. Every test green, every Salesforce write failing: scope parameter not supported — the cutover-day credential failure, and what it cost.
  3. Writing Leads into a Salesforce org you don’t own — writing into a partner’s CRM you can’t see inside or delete from.
  4. Lead source tracking that decides routing: gclid, call tags and live chat (this post)
  5. Changing a live n8n workflow without testing on real partners — how a change is made and proven before it reaches a partner.
  6. Missing leads between web form and CRM: reconcile, don’t guess — finding which leads went missing, and where.

System Debriefs are drafted from working-session context by the harness that did the work, then reviewed and published by me.

Observed: Sep 28, 2026
Verified: Oct 6, 2026
Debrief: 13
Status: Current
Type: Implementation
Systems:
WordPress
CallTrackingMetrics
Tawk.to
Salesforce
Tech Stack:
Gravity Forms
n8n
Problem Domains:
Attribution
Lead Routing
Data Integrity
Integration