Skip to content

Join the Seedly owners community →

From the blog

Conversion Tracking Setup for Google Ads, Meta and Local Services Ads: The Step-by-Step Runbook

The full, repeatable setup for tracking leads and real revenue from Google Ads, Meta Ads and Google Local Services Ads. Account settings, tracking templates and the overrides that silently beat them, the website scripts that keep the ad click alive from page to page, call tracking, closed-loop revenue, and a test checklist to prove it all works.

Andrew Lee Jenkins43 min readOperations
Conversion Tracking Setup for Google Ads, Meta and Local Services Ads: The Step-by-Step Runbook

Most local businesses running ads have exactly one conversion wired up. The contact form. Somebody fills it out, the ad platform counts a conversion, everybody nods.

Then the ad account starts doing something quietly expensive. It goes and finds you more of whatever produced that form fill, because that is the only outcome you ever told it to care about. Tire kickers count the same as a signed job. A wrong number counts the same as a $9,000 install.

You end up with a beautiful cost per lead and a business that is not making more money.

The fix is not a better ad. It is telling the ad platform what actually happened after the lead came in. This guide is the whole setup for Google Ads, Meta Ads and Google Local Services Ads, written as a runbook. It is the same list I work through every time I set tracking up for a new client, in the order I do it, with a test at the end that proves each piece works before anybody spends a dollar.

How to use this runbook

This runbook is written for Seedly CRM 5.9.5 and later. On an older version, update first. Where a step behaved differently in an earlier version, the step says so.

Work top to bottom. Each part depends on the one before it, and most of the pain in tracking comes from doing a later step before an earlier one (pasting a tracking template before checking what overrides it, or embedding a form before the click is being carried to it).

1. Connect accounts and create conversion actions
Where you do it
Seedly, Google Ads, Meta Events Manager
How often
Once per client
2. Google Ads click capture
Where you do it
Google Ads account, campaigns, ad groups, ads
How often
Once per Google Ads account, then a re-check whenever someone adds campaigns
3. Website scripts and forms
Where you do it
The client's website and Seedly
How often
Once per website
4. Meta Ads
Where you do it
Meta Ads Manager and Seedly
How often
Once per Meta ad account, URL parameters on every new ad
5. Google Local Services Ads
Where you do it
Seedly and the LSA profile
How often
Once per LSA profile
6. Revenue (closing the loop)
Where you do it
Seedly workflows
How often
Once per client
7. Verification checklist
Where you do it
A private browser window, a phone, Seedly
How often
Every setup, and after any website rebuild

The three signals worth tracking

Every lead reaches you one of two ways, and every lead either turns into money or does not. That gives you three things to report back.

Lead from a form
Fires when
Somebody submits a form
Value sent
Optional or estimated
Lead from a call
Fires when
A tracked call runs long enough to be real
Value sent
Optional or estimated
Customer Paid
Fires when
An invoice actually gets paid
Value sent
The real amount collected

The first two tell the platform "this ad produced a human." The third tells it "this ad produced money." Smart Bidding on Google and Advantage bidding on Meta can only optimize toward what you feed them, so if you stop at the first two, you have asked them to buy leads, not customers.

Seedly is built to send all three, from forms, from tracked phone calls, and from paid invoices, to Google and to Meta. Local Services Ads work differently, and Part 5 covers exactly what you can and cannot track there.


Part 1. Connect the accounts and create the conversion actions

Step 1. Connect your ad accounts

Google Ads. Open Settings and Integrations, find the Google Business card, and connect your Google account. That single connection covers Analytics, Search Console, and Ads. Once it is connected, pick which Ads account to link. The connected user needs standard or admin access on the Google Ads account, not read-only.

The Google Business card in Seedly Settings and Integrations, connected, showing the account email and a green Connected badge.

Already had Google connected? Reconnect it once. Google added a new permission that server-sent conversions now require, and a connection made before that permission existed does not carry it. Nothing looks broken when this is the problem, which is exactly why it is worth ruling out now. The card still says Connected, your reports still work, and the conversions just wait instead of going through. Disconnect Google on the Google Business card, connect it again, and approve the permissions Google asks for on the way back in. Anything that was waiting gets sent once the reconnect lands. You only have to do this once.

The Meta Conversions API card in Seedly, with fields for the Pixel ID, a CAPI access token, a test event code, and a Send test event button.

Meta. Open Settings and Integrations, find the Meta Conversions API card, and paste two values. Your Pixel ID (Meta now labels this a Dataset ID inside Events Manager, it is the same number) and a CAPI access token. Both live in Meta Events Manager under your dataset's Settings tab. Use the Send test event button to confirm the connection lands before you go further.

Step 2. Create the conversion actions

This part happens inside Google Ads and Meta, not inside the CRM, and it is where most people go wrong.

A conversion action is the bucket Google counts into. You create it. The CRM fires at it.

Because Seedly sends conversions from your server (not from a browser tag), you have to create the right type of conversion action. In Google Ads, go to Goals, Conversions, Summary, and create a new conversion action. Google has been rewording this flow, so you will see one of two shapes. Older accounts show Import, then Manual import using the API or uploads. Newer ones call the choice Conversions offline and then walk you through an Add an offline data source step, where the option to pick is Skip this step and set up a data source later, which its own fine print confirms is the uploads and Google Ads API route. Either way you end up with an offline conversion action of the type the CRM can send to.

Google Ads' Add an offline data source step, with "Skip this step and set up a data source later" selected, the manual route whose description names uploads and the Google Ads API.

Do not create a Website or a Phone-call conversion action. Those cannot receive server-sent conversions, and the CRM will not be able to deliver to them.

Create these before you configure anything in the CRM. A brand new Ads account has no conversion actions at all, so there is nothing for an upload to attach to and nothing for the CRM to point at. The conversion action dropdowns inside Seedly read live from the Ads account you linked, and on an empty account they come back with nothing to choose. Those dropdowns also list every conversion action in the account, Website and Phone-call ones included, so pick the Import ones you just made.

Make two.

  1. Lead, for form fills and qualifying calls. A good goal category here is Qualified lead or Converted lead.
  2. Customer Paid, for actual revenue.

If you would rather see form fills and calls as separate columns in Google Ads, make two lead actions instead of one, a form lead and a call lead, and point your forms at one and your call pool at the other. Same mechanics, one extra bucket.

While you are in the settings for each action, set the count to Every, leave the click-through conversion window at its default (Google commonly allows up to 90 days), and choose Data-driven or Last click attribution. Once you have a steady flow of paid customers, mark Customer Paid as your primary conversion so bidding targets return on ad spend.

The settings panel of a Google Ads import conversion action, with Source reading Import from clicks, Count set to Every conversion, a 90 day click-through conversion window, and Data-driven attribution marked Recommended.

Advertising for more than one service? You still only need these two. A single Lead action and a single Customer Paid action can be shared across every campaign and every service you run, roofing, gutters, drain cleaning, all of it. Google already reports performance per campaign, so you can see how each service is doing without a separate conversion action for each one. And because the CRM sends the real amount collected as the value, that one Customer Paid action reflects a $400 job and a $40,000 job correctly on its own, with no extra setup.

The one reason to split them is if you want to bid to a different return-on-ad-spend target per service. Then create a matching pair for each one, say a Roofing Customer Paid and a Gutters Customer Paid, set each as the primary conversion on its own campaign, and point that service's forms, call pools, and Customer Paid workflow at its matching action. It is more moving parts to keep straight, so only reach for it when the services differ enough in value that one blended target would misprice them.

Meta

Meta does not use per-account conversion actions the way Google does. It uses standard event names on your dataset. You do not create anything ahead of time. The CRM sends a Lead event for form fills and qualifying calls, and a Purchase event when an invoice is paid, and those events show up in Events Manager on their own.

Step 3. The Google Ads account settings that let conversions through

These live in your Google Ads account, not in the CRM, and none of them announce themselves. Miss one and the CRM looks broken when it is not, so walk the list once now rather than debugging it later.

Accept the customer data terms. In Google Ads, go to Goals, Conversions, Settings, and accept the customer data terms. Do this one first. Until they are accepted, uploads come back as "Resource not found" pointed at an internal field name, which reads exactly like a bug in the software and is not one. Once the terms are accepted, Google starts returning errors that name the actual cause, which is the difference between a five-minute fix and a lost afternoon.

Turn on enhanced conversions and set the method to Google Ads API. This is what lets a conversion match on a hashed email address or phone number alongside the click ID. If the method is set to anything else, the match on contact details does not happen.

Enhanced conversions for leads is a separate setting. It has its own switch and its own gate to get through, and it covers a different case, a conversion that arrives with no click ID at all. Its setup only offers the Google tag or Google Tag Manager as the method. There is no API option in there, and going looking for one is where a lot of people lose an hour.

Enhanced conversions
What it does
Matches on a hashed email or phone alongside the click ID. Method has to be Google Ads API.
When you need it
Raising match rates on conversions that already carry a click ID
Enhanced conversions for leads
What it does
Matches on contact details when there is no click ID. Set up with the Google tag or Tag Manager only, no API option.
When you need it
Crediting leads that arrived with no click ID at all

A conversion that carries a click ID needs neither of them. If the lead arrived with a gclid, gbraid, or wbraid, Google can credit it on the click alone. Get that working and confirm conversions are landing before you go anywhere near identity matching. A baseline you trust makes the next part obvious instead of guesswork.

Give a saved setting time to take. Saved is not the same as live. Google Ads showed enhanced conversions as active while uploads were still being rejected for it, so if the next upload still fails, wait around ten minutes and try again before you change anything else. Changing three settings while the first one is still catching up is how a working setup gets pulled apart.

There is no Meta equivalent of these settings. Meta matches on hashed contact details automatically when you send them, which the CRM already does, so there is nothing to switch on and no terms to accept.


Part 2. Google Ads click capture

Before any conversion can be credited to an ad, the lead has to arrive carrying the click identifier. On Google that is the gclid (plus gbraid and wbraid on newer campaign types). Without one, Google has no way to tie the conversion back to the ad click, and the conversion is dropped. This part makes sure every Google click lands on the website carrying it, plus the campaign name.

Step 4. Turn on auto-tagging

In Google Ads, open Admin, then Account settings (older accounts show this under Settings, Account settings), and expand Auto-tagging. Tick the box to tag the URL that people click through from your ad, and save. This is what appends the gclid to your landing page URL.

Step 5. Install the account-level tracking template

In the CRM, open Settings and Integrations, go to the connected Google card, find the Conversion Tracking section, and copy the Tracking template. The section only appears after you have selected an active Ads account, so pick your account first if you do not see it.

The Conversion Tracking section on Seedly's Google card, showing the tracking template in a code box with a Copy button and paste instructions for Google Ads.

This is the template it gives you, so you can check you copied all of it.

{lpurl}?utm_source=adwords&utm_medium={adname}&utm_campaign={campaignname}&utm_content={adgroupname}&utm_keyword={keyword}&utm_matchtype={matchtype}&campaign_id={campaignid}&ad_group_id={adgroupid}&ad_id={creative}&gclid={gclid}&gbraid={gbraid}&wbraid={wbraid}

Paste it into Google Ads under Admin, Account settings, Tracking, in the Tracking template field, and save. Use the Test button next to the field if Google offers it, and confirm the test URL comes back with your landing page and the parameters attached.

The CRM stores utm_source, utm_medium, utm_campaign, utm_term and utm_content on the lead, plus the gclid, gbraid and wbraid. The campaign name is the one that matters most, because it is what lets the CRM put revenue against a named campaign later. The other values in the template (keyword, match type, the numeric IDs) still ride along on the landing page URL where your website analytics can see them, but the CRM does not store them on the lead.

Step 6. Check that nothing overrides the account-level template

This is the step that gets skipped, and skipping it can make Step 5 do nothing at all.

Google Ads uses the most specific tracking template it can find. A template set on a campaign beats the account one. A template on an ad group beats the campaign. A template on an ad or a keyword beats the ad group. The same goes for the Final URL suffix field that sits next to each one. So if an account that has been through another agency or another tracking tool carries its own template on every ad group, your account-level template is never used. Google does not warn you about this. The account-level field shows your template, saved and valid, and every real click goes out with somebody else's.

Check each level before you trust Step 5.

Account
Where to look in Google Ads
Admin, Account settings, Tracking
What you want to see
The CRM template from Step 5
Campaign
Where to look in Google Ads
Open the campaign, go to Settings, expand Campaign URL options
What you want to see
Tracking template and Final URL suffix both empty
Ad group
Where to look in Google Ads
Open the ad group, go to its Settings, look under Ad group URL options
What you want to see
Both empty
Ad
Where to look in Google Ads
Edit the ad and expand Ad URL options
What you want to see
Both empty
Keyword
Where to look in Google Ads
Edit the keyword and look at its URL options
What you want to see
Both empty

Clicking through every campaign and ad group by hand is slow on a big account. Google Ads Editor is faster. Download the account, then look at the Tracking template and Final URL suffix columns on the Campaigns, Ad groups, Ads and Keywords lists. Sort each column so any filled-in values come to the top. Every row with a value there is an override.

When you find overrides, you have two choices.

  1. Clear them, so the level inherits the account template. This is the clean fix, and it means the next person who changes the account template changes it everywhere.
  2. Replace them with the same CRM template, if there is a real reason that level needs its own (rare, and worth writing down why).

Fix campaigns and ad groups first. Editing an ad itself is a heavier change in Google Ads than editing a campaign or ad group setting, so do ad-level fixes last and read what Google says on the edit screen before you save.

Re-check this whenever someone builds new campaigns in the account. A campaign copied from an old one copies its URL options too.


Part 3. The website, once per site

Everything in Part 2 gets the click ID onto the first page a visitor lands on. Part 3 makes sure it survives the trip from that page to the place where they actually call or fill out the form.

Install these in this order, on every page of the site.

Step 7. Call tracking with dynamic number insertion

If the same phone number sits on your website for every visitor, a phone call is an anonymous event. Somebody called. You have no idea whether they came from an ad, a Google Maps listing, or a fridge magnet from 2019.

Dynamic number insertion fixes that by swapping the number on the page depending on who is looking at it. Ad traffic sees one number, organic traffic sees another. Because the number is different, the call itself carries the attribution.

7a. Set the default website phone number first. Open Settings, then the Details tab, and fill in Default phone number on website with the number currently published on the site. Type it however you like. Seedly saves it as +1 and 10 digits, which is the form the script searches the page for, and it refuses a number that could never be found on a page. This is the number the script swaps for any pool that has no Original Number of its own. Fill it in even if you also set the number on the pool below. On versions before 5.9.4, the site-wide script did nothing at all when this field was empty, and a number saved in another format, like (512) 555-0000, was never found in the page text.

7b. Turn on call tracking and build the pool. Open Settings and Integrations, click the Configure Telephony card, and go to its Tracking tab. Turn on Enable Call Tracking, then create a tracking pool and assign several phone numbers to it. Four or more numbers gives the most accurate visitor-level attribution.

Set the match rules so the pool applies to the right visitors. You can match on the Google click ID being present to catch all Google Ads traffic, on the Facebook click ID for Meta, or on any UTM combination for a specific campaign. Pools are checked in priority order, and you can flag one as the default so visitors who match nothing still see a sensible number.

Turn on DNI and enter the Website URL where the script runs (this registers your site for number swapping, no developer needed). You can skip the CSS selector field. The current script finds your number on its own wherever it appears, in page text, in tap-to-call links, even in accessibility labels, and in whatever format you typed it, then keeps watching the page so anything that loads late gets swapped too. The selector exists for the rare site where the number is split across separate elements and never appears as one readable string. Point it at the element that holds the number if that describes your site, and ignore it otherwise.

Set the Original Number on the pool too, the same number from 7a, unless visitors matched to this pool see a different number on the site. Each visitor's page is searched for the number of the pool they matched, so pools with different numbers each swap their own. The pool's Get embed code button needs an Original Number on a pool or the default from 7a. With neither, it tells you what to fill in instead of giving you code. Versions before 5.9.4 handed over an older embed in that case, and the older script swaps the number on the page but records nothing about the visitor who saw it, so no call conversion can ever fire. If a site has a call tracking script with ?pool= in its address, that is the older one: replace it with the current embed from 7c. Filling in both fields is the habit that rules all of this out.

A Seedly tracking pool being configured, with dynamic number insertion on, a CSS selector, a Website URL, an Original Number, and visitor match rules.

7c. Install the script. Grab the embed code from the pool's Get embed code button. With the original number filled in you will see the current version marked as recommended (its address ends in ?account=), which is the one to copy. It is a single script tag. Put it in the site header so it loads on every page, the same place as the script in Step 8.

7d. Turn on call conversions for the pool. You get both platforms here.

The tracking pool's Google Ads and Meta CAPI sections in Seedly, each with a Qualify after seconds field, a value override, and a currency override.

  • Google Ads, "Send conversion when call qualifies", pick your Lead conversion action.
  • Meta CAPI, "Send Meta CAPI event when call qualifies", event name Lead.

Both have a Qualify after setting measured in seconds. This part is worth caring about. Without a duration threshold, a two-second misdial reports as a conversion and you have taught the platform that wrong numbers are a good outcome. Pick a length that means a genuine conversation for your business and let short calls fall through.

Step 8. Keep the ad click alive from page to page

Here is the gap almost every setup has. Google sends the visitor to a service page with the gclid and the campaign name in the address bar. They read for a minute, click Contact in the menu, and fill out the form there. The contact page's address has none of the ad parameters, because nothing carried them across. The form looks at its own page, finds nothing, and the lead arrives as if it came from nowhere.

The CRM's form embed and its call tracking script both read the ad parameters from the address of the page they are on. So the fix is to keep those parameters in the address as the visitor moves around the site.

This small script does that. When a visitor lands with ad parameters, it saves them for the rest of their visit, then adds them to every link that points to another page on the same site, including links that appear after the page has loaded. Install it once per site, in the header, on every page.

<script>
(function () {
  const KEYS = [
    "utm_source","utm_medium","utm_campaign","utm_term","utm_content",
    "utm_id","gclid","gbraid","wbraid","fbclid","ref","msclkid"
  ];
  const STORAGE_KEY = "persisted_utm_params_v1";

  function collectFromLocation() {
    const src = new URLSearchParams(window.location.search);
    const kept = {};
    let found = false;
    KEYS.forEach(k => {
      if (src.has(k)) { kept[k] = src.get(k); found = true; }
    });
    if (found) {
      sessionStorage.setItem(STORAGE_KEY, JSON.stringify(kept));
      return kept;
    }
    const saved = sessionStorage.getItem(STORAGE_KEY);
    return saved ? JSON.parse(saved) : {};
  }

  function decorateUrl(href, params) {
    if (!href) return href;
    if (href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:") || href.startsWith("javascript:")) return href;

    let url;
    try { url = new URL(href, window.location.origin); } catch(e){ return href; }

    // Only decorate same-origin links
    if (url.origin !== window.location.origin) return href;

    Object.keys(params).forEach(k => {
      if (!url.searchParams.has(k)) {
        url.searchParams.set(k, params[k]);
      }
    });
    return url.toString();
  }

  function decorateAll(params, root=document) {
    if (!params || Object.keys(params).length === 0) return;

    root.querySelectorAll("a[href]").forEach(a => {
      const newHref = decorateUrl(a.getAttribute("href"), params);
      if (newHref) a.setAttribute("href", newHref);
    });

    root.querySelectorAll("form[method='get' i]").forEach(form => {
      Object.entries(params).forEach(([k,v]) => {
        if (!form.querySelector(`input[name="${k}"]`)) {
          const hidden = document.createElement("input");
          hidden.type = "hidden";
          hidden.name = k;
          hidden.value = v;
          form.appendChild(hidden);
        }
      });
    });
  }

  function run() {
    const params = collectFromLocation();
    decorateAll(params);

    const mo = new MutationObserver(muts => {
      const p = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || "{}");
      muts.forEach(m => m.addedNodes.forEach(n => {
        if (n.nodeType === 1) decorateAll(p, n);
      }));
    });
    mo.observe(document.documentElement, { childList: true, subtree: true });
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", run);
  } else {
    run();
  }
})();
</script>

Where it goes. In the site's header, on all pages. On WordPress the simplest route is a header and footer code plugin, such as Header Footer Code Manager: add a new snippet, paste the whole block, set it to load site-wide in the header, and save. On other platforms, use whatever field the site builder offers for code that loads in the head of every page.

What it does and does not touch. It only adds parameters a link does not already have, and it leaves alone anchor links, email links, phone links and anything pointing at a different website. It keeps the saved values for the browser tab's session, so a visitor who comes back tomorrow starts fresh. If a visitor arrives a second time in the same session through a different ad, the new parameters replace the old ones.

Embed the form, do not link out to it. Because the script only decorates links on the same site, a button that sends visitors to the CRM's hosted form page on a different domain goes out without the parameters. Embed the form on a page of the client's site instead (Step 9), and point your buttons at that page.

Step 9. Forms

9a. Embed the form fresh from Share. Open the form in the CRM, click Share, go to the Embed tab, and copy the code. Paste it onto the page as it comes, and do not edit it by hand. The embed carries a small script that reads the ad parameters from the page address and passes them into the form. A hand-edited embed once renamed the frame that script looks for, and every ad lead through it lost its click ID without a single error showing anywhere. Current embeds and the call tracking script both have a fallback for that now, but the reliable habit is the same: paste it fresh, and re-paste it after any change on the CRM side. An embed copied before the form passed ad details through does not pass them at all, and a lead through it looks like it came from nowhere. Forms opened on their own hosted page capture everything without an embed.

9b. Turn on tracking for the form. Open the form in the builder, go to the Settings tab, and open the Tracking section. Turn on the platform you want.

  • Google Ads. Turn on "Send conversion when this form is submitted" and pick your Lead conversion action from the dropdown, which is populated live from your Google Ads account.
  • Meta. Turn on "Send Meta CAPI event when form submits" and leave the event name as Lead.

You can set a value override on either, or leave it blank to send no value for a lead. That is fine. Leads are counted, revenue comes later.

The form builder Settings tab in Seedly, Tracking section, with separate toggles for Google Ads, Meta Conversions API, and Google Analytics 4.

The part that matters and that you do not have to configure is the click ID. When somebody arrives from an ad and submits the form, the CRM stores the gclid or fbclid and the UTM values on the new contact. That is what lets a conversion three weeks from now still get credited to the ad click that started it.

9c. Say yes to the opportunity. The first time you turn on tracking for a form, Seedly asks if you want to auto-create an opportunity when that form is submitted. Say yes. The opportunity is what carries the ad click all the way through to the paid sale, and Part 6 explains exactly why that matters. You only answer this once per form.

The opportunity gets its Source from the visit that filled in the form: a visit that arrived with utm_source=adwords creates an opportunity with Source adwords, and a visit with no utm_source gets form. Each submission is judged on its own, so a person who first came from an ad and later came back on their own gets one of each. (Before 5.9.5, every form opportunity was Source form.)

There is also a toggle to create opportunities only for ad-sourced leads, meaning fills that arrived carrying a click ID or a UTM source. Left off, every fill creates an opportunity, which is the default and the simplest thing to reason about. Turn it on if you only want ad traffic landing in the pipeline.

9d. Optional: copy a link value onto a custom field. The CRM already stores the click ID and the five utm_ values on the contact without any setup. If you also want one of them on a custom field, for example the utm_campaign on the opportunity itself, add a hidden field to the form, set its label to the parameter name (utm_campaign), open it, and use Save to CRM to pick a contact or opportunity custom field. Leave the hidden field's Default empty. With a Default set, the Default is always what gets saved and a visitor cannot change it through the link. The value is only saved to a custom field in the same client account as the form. An embedded form only receives the five utm_ values and the click IDs (gclid, gbraid, wbraid, fbclid) from the page, so a parameter of your own only reaches a hidden field on the form's hosted page. (Available from 5.9.5.)


Part 4. Meta Ads

Meta's click ID is the fbclid. Meta adds it to the link when somebody clicks through from an ad, the website scripts in Part 3 carry it from page to page, and the CRM captures it. There is no tracking template to paste for Meta. What you do set up is below.

Step 10. Add URL parameters to every Meta ad

The fbclid proves the visit came from Meta, but it does not carry a campaign name the CRM can read. Meta's URL parameters field does. In Ads Manager, open the ad, scroll to Tracking, and fill in URL parameters. A consistent convention looks like this.

utm_source=facebook&utm_medium=paid&utm_campaign={{campaign.name}}&utm_content={{ad.name}}&utm_term={{adset.name}}

The parts in double braces are Meta's own placeholders, filled in with the real names at click time. Pick one spelling for utm_source and keep it across every client, because a match rule or a report that looks for facebook will not find Facebook or fb.

This field lives on each ad, so it has to be there on every new ad too. Duplicating an ad that already has it carries it over.

Step 11. Route Meta visitors to a Meta call pool

If the client takes calls from Meta traffic, add a tracking pool whose match rule is the Facebook click ID being present (or utm_source equal to your Meta spelling), and turn on its Meta CAPI call conversion as in Step 7d. Pools are checked in priority order, so decide which one wins for a visitor who somehow carries both kinds of click ID, and order the two pools that way on purpose.

Step 12. What the CRM sends to Meta

Meta's Conversions API sends events from the server, which is exactly what the CRM does after a form is accepted, a call qualifies, or an invoice is paid. That is the modern, recommended way, and it is more durable than a browser pixel that iOS and ad blockers keep degrading.

The CRM sends hashed email, phone, name, city, state, and zip with every Meta event, plus the click ID built from the fbclid when the lead arrived with one, which is what gives Meta enough to match the event to a real person.

The Meta Pixel on the website. If the client also runs a Meta Pixel, use it only for top-of-funnel signals like page views and for building retargeting audiences. Do not fire Lead or Purchase from both the browser Pixel and the CRM for the same event, or Meta may count it twice. Keeping the conversion events on the CRM side and the page-view signals on the Pixel side means there is no overlap and nothing to deduplicate.

One limit worth knowing. The Pixel also sets a browser cookie Meta uses for matching, and the CRM's form reads that cookie only when the form itself is running on a page that can see it. A form embedded on the client's site runs inside a frame on the CRM's own address, which cannot read the client site's cookies, so embedded forms send the fbclid and the hashed contact details but not that cookie. The fbclid is the part that ties the event to the ad click.


Part 5. Google Local Services Ads

Local Services Ads are different from everything above, and it is worth being clear about why before you set anything up.

LSA leads do not land on the website. Somebody taps the Google Guaranteed listing and calls. There is no landing page, so there is no click ID, no tracking template, no URL parameters, and nothing for the website scripts in Part 3 to carry. None of Parts 2 to 4 applies to LSA. What you can track is the phone call itself.

Step 13. Give the LSA profile its own tracking number

  1. Open Settings and Integrations, click the Configure Telephony card, and buy a number on the Numbers tab (or use one you own that is not published anywhere else). It has to be dedicated to LSA, or the CRM cannot tell an LSA call from any other call.
  2. On the Tracking tab, make sure Enable Call Tracking is on, and create a tracking pool with Traffic Source set to Google Local Services (LSA). That choice turns Record calls on and fills in the whisper "Google Local Services lead", which plays only to whoever answers. Set Forward To to the phone that should ring, and tick the number.
  3. Leave DNI off for this pool. Nobody is swapping numbers on a web page here.
  4. In Voicemail & Routing, make sure inbound calls are set to Forward to number. The pool's whisper and greeting only play on the forwarding path.

Step 14. Turn on the Local Services lead assistant

Below your tracking pools there is a section called Local Services lead assistant. Switch it on, pick who gets the emails under Email these people (left empty, it goes to the account's own owners and admins), and click Save. It uses the same AI model and key as AI call grading, so set that up first. Without AI the emails still arrive, with a generic review step instead of the details.

From then on, each call on the LSA number is recorded and transcribed, and the people you picked get one email per call that says what to do in the Google Ads lead inbox: mark it Booked with the job type, or rate the lead and give the reason. Google does not let software mark an LSA lead Booked for you, which is why this is an email and not an automatic update.

Step 15. Put the number in the LSA profile

In Google Ads, open the Local Services profile and change the business phone number to the number from Step 13. Do this last, once the test call in Part 7 works. Google may review the profile when the phone number changes, so plan for a short pause.

What LSA attribution you get, and what you do not

What you get for LSA

  • Every call on the LSA number is recorded, transcribed and summarized.
  • One email per call saying exactly what to mark in the Google Ads lead inbox.
  • New callers are saved with the contact source Local Services Ads.
  • Won deals from those callers count as Local Services revenue on the Paid Ads tab, with their own ROAS row.

What you do not get for LSA

  • A gclid or any other click ID, so no offline conversion upload back to Google.
  • A campaign, ad group or keyword name on the lead.
  • Marking the lead Booked automatically. The lead inbox stays a manual step, which is what the email is for.
  • Leads that come in as messages inside Google. They never touch the tracking number, so the CRM never sees them.

The Paid Ads headline ROAS leaves Local Services out of both spend and revenue and is labelled ROAS excl. LSA, so LSA spend is not counted against revenue it usually cannot be credited with. Local Services keeps its own row.


Part 6. ROI tracking (closing the loop)

Everything up to now still only reports leads. This part is the one almost nobody does, and it is the one that changes what the ad platform actually buys.

Step 16. Turn on the Customer Paid workflow

There are two ready-made workflows for this, one for Google Ads and one for Meta CAPI. In your Workflows list both names end in Customer Paid (revenue tracking), one starting with Google Ads and the other with Meta CAPI. A sub-account created since these landed gets both automatically, switched off and sitting in draft, waiting on you.

Open your Workflows list and look for them before you go any further. A sub-account older than the feature will not have them, and no amount of hunting will turn them up. You are not missing something. In that case build each one from the template instead. Open Create Workflow, then Use a Template, and pick it out of the dropdown by the same name. It arrives in draft exactly like the seeded copy. If you run several sub-accounts, ask whoever administers your CRM to backfill them all at once rather than doing this by hand.

The shape of each is two steps. An invoice gets paid, and a conversion fires. The value it sends is not a guess or a flat number. It is the amount actually paid, including tip, late fees, and processing fees, pulled straight from the invoice that just got paid, along with its currency.

To turn on the Google one, open it, pick your Customer Paid conversion action in the node, and switch it from draft to published. To turn on the Meta one, open it, confirm the event is Purchase (the default), and publish it. That is the whole job.

These fire on every paid invoice, not just the first sale to a customer. A repeat buyer's second and third purchases each send their own revenue conversion with their own amount, so a customer who comes back is worth what they actually spent, not counted once and forgotten. It also does not matter how the payment was recorded. A card charge, a cash or check payment you mark as received, or the final payment on an installment plan all fire the same conversion.

They stay in draft until you activate them, which is deliberate, because a workflow firing at nothing is worse than one that never ran.

The Google Ads Customer Paid workflow open in Seedly's builder, an Invoice Paid trigger flowing into a Send Google Ads Conversion action, with the value pre-wired to the amount actually paid.

Now the loop is closed. Google sees the ad click, the lead, and the $9,000 that showed up eleven days later, and it can go find more clicks that look like that one. Meta gets the same Purchase signal against the same visitor.

How the money finds the ad click, weeks later

When an ad lead fills out your form and an opportunity is created, the CRM saves that visit's ad click onto the opportunity itself, frozen in place. When you bill that sale, create the invoice from the opportunity. When the invoice is paid, the workflow reads the click that is saved on the deal and sends the paid amount to the platform, tied to that exact click. You do not have to do anything to connect them beyond billing from the opportunity. The CRM keeps the thread.

Saving the click on the deal is what keeps a sale honest over time. A repeat customer clicks more than one ad across the months you work with them, and a busy contact can have several deals running at once. Because each deal holds the click that started it, the payment on one deal credits the ad that produced that deal, not whichever ad the person happened to click most recently. If an invoice is not linked to an opportunity, the workflow falls back to the contact's most recent click, which still works but is only as precise as last touch. That is why billing from the opportunity is the habit worth keeping.

Calls follow the same thread. If you use a workflow's Create Opportunity step to open an opportunity when a tracked call comes in, the opportunity keeps the ad click the caller arrived with, so a call that turns into a won sale credits its ad the same way a form does. With Prevent Duplicates on, an opportunity that is already open keeps its own click. (Before 5.9.5, opportunities made by that step had no click, so a won call sale could not be reported to Google or Meta.)

If you do not invoice through the CRM

Plenty of businesses close deals in the CRM and bill somewhere else entirely, QuickBooks, a field service app, a card reader in the truck. For them the invoice-paid trigger never fires, and the revenue signal never leaves the building.

The workflow does not care where its trigger comes from, so move it. Open the Customer Paid workflow, swap the trigger from invoice paid to Opportunity Status Changed with the status set to won, and set the conversion value to the deal value token, {{deal.value}}. Now marking a deal won is the moment the revenue reports, the amount sent is whatever the deal says it is worth, and the click frozen on the deal still does the matching, so nothing else about the setup changes.

That trade comes with two habits. Put the real amount on the deal before you mark it won, because whatever number is sitting there at that moment is the number Google hears. And treat won as a one-way door. Toggle a deal out of won and back in and the conversion can send again, which counts the same sale twice.

If leads or payments arrive from another system

Some businesses take requests or payments in a separate tool, a booking app or a field service app with its own request form. If that tool can send a webhook, point it at an Inbound Webhook workflow and add a Send Google Ads Conversion step. Two fields in the webhook body make this work (from 5.9.5):

  • The click ID. Put gclid, gbraid or wbraid at the top level of the body. The step uses the opportunity's click first, then the one in the webhook, then the contact's, and it works even when the webhook cannot be matched to a contact. The website still has to get the click ID to that tool's form, which is what Step 8's script and the tool's own form settings are for.
  • A key for each conversion. Put a conversionDedupKey in the body (or an invoiceId, used when no key is sent), different for each payment or each request, never one per customer. A webhook delivered twice then counts once in Google Ads and Meta, while a customer's second payment still counts as a second sale.

Part 7. Verification checklist

Run this on every setup before you call it done, and again after any website rebuild or theme change. Use a private browser window so nothing from your own earlier visits gets in the way. Use an email address you control and a name you will recognize, like "Runbook Test".

Google Ads account

  • Auto-tagging is on (Step 4).
  • The account-level tracking template matches the CRM template exactly (Step 5).
  • No campaign, ad group, ad or keyword carries its own tracking template or final URL suffix, or each one that does carries the same template on purpose (Step 6).
  • The Lead and Customer Paid conversion actions exist, are the import type, and are selected in the CRM (Steps 2 and 9b).

Form path, landing on an inner page

  • Open a page that is NOT the form page, with test parameters on the end, for example https://example.com/services/roofing/?gclid=TEST-RUNBOOK-1&utm_source=adwords&utm_medium=test&utm_campaign=runbook-test.
  • The phone number on the page has swapped to a number from your Google pool, in the page text and in any tap-to-call link.
  • Click through to the contact page using the site's own menu, not by typing the address. The address bar on the contact page still carries gclid=TEST-RUNBOOK-1 and the utm_ values. If it does not, the Step 8 script is missing or not loading on that page.
  • The phone number on the contact page is still the Google pool number.
  • Submit the form.
  • In the CRM, open the form and its Submissions tab. The new submission's Source column reads adwords.
  • The new contact exists, and an opportunity was created for it if you turned that on. The opportunity's Source reads adwords.
  • On the dashboard, open the Paid Ads tab and scroll to Recent conversion uploads. A row with the source Form appears for the test. Because the click ID is made up, Google will reject it, and the Detail column says why. That is the expected result for a test. A real ad click is the only thing Google can match.
  • Start an invoice for the test contact from its opportunity. The invoice editor should say Ad revenue tracking: deal-level, meaning the click is frozen on the deal and the Customer Paid conversion will credit it. Discard the invoice without sending it.

Call path

  • In the same private window, on a page still showing the Google pool number, call that number from a phone that is NOT the one the pool forwards to.
  • Stay on the line longer than the pool's Qualify after time.
  • The call shows in the CRM's call log against the pool, and the caller's contact picks up the campaign and click ID that the visitor arrived with.
  • If the pool's Google Ads conversion is on, Recent conversion uploads gets a row with the source Call.

Meta

  • On the Meta Conversions API card, Send test event lands in the Test events tab in Meta Events Manager.
  • Repeat the form path with fbclid=TEST-RUNBOOK-2&utm_source=facebook&utm_medium=test&utm_campaign=runbook-test on the inner page, and confirm the parameters survive to the form page. Note that a real form submission sends a real Lead event to Meta (the test event code is only used by the Send test event button), so expect that one test Lead to show in Events Manager.
  • Open one live Meta ad and confirm its URL parameters field is filled in (Step 10).

Local Services Ads

  • Call the LSA tracking number from a phone that is not the forwarding phone, play a customer, and book a fake job.
  • Whoever answers hears the whisper. The caller does not.
  • Within a few minutes, the people you picked get the lead email, telling them to mark the lead Booked with the job type.
  • The caller is saved as a new contact with the source Local Services Ads.
  • Only now change the phone number in the LSA profile (Step 15).

Clean up

  • Delete the test contacts and their opportunities so they do not sit in the pipeline or the Paid Ads numbers.

A few things that will bite you

Overrides you did not set. A tracking template or final URL suffix on a campaign, ad group, ad or keyword beats the account level, silently. Re-run Step 6 whenever new campaigns appear in an account, especially ones copied from older campaigns.

A form page reached without the parameters. If the address bar on the form page is clean, the form has nothing to read, no matter how well everything else is set up. That is what Step 8 exists for, and it is the first thing to check when ad leads start arriving as unattributed.

Double counting on Google. Forms, workflows, and call pools are tracked separately on purpose, so one lead can trigger more than one signal without inflating your numbers. You do not need to build guards against it.

Reconnecting Google. Google occasionally updates the permissions it requires to receive conversions. If your Google conversions stop appearing and nothing else changed, reconnect Google from Settings and Integrations before you start tearing the setup apart. A row in Recent conversion uploads reading Waiting on permission is that problem.

Accepted is not matched. When the CRM sends a conversion and the platform accepts it, that means the request landed, not that it was matched and counted. Both Google and Meta show the matched result in their own dashboards a day or two later. Judge the setup there, not on the instant the CRM fires.

The lag is real. Revenue conversions land whenever the invoice gets paid, which might be well after the click. That is the whole point of offline conversion tracking, but it does mean the numbers in your ad account keep filling in behind you. Judge a campaign on a window that has had time to settle rather than on yesterday.

What changes once it is running

You stop optimizing for the cheapest lead and start optimizing for the most profitable one. Those are frequently not the same campaign, and until the revenue signal is flowing you have no way of knowing which is which.

Most accounts discover the cheap-lead campaign they were proud of produces almost nothing, and the expensive one they nearly paused pays for everything else.

You cannot see that with a form-fill conversion. That is the whole reason to do the rest of it.

You are not buying alone.

Seedly owners trade setups and share add-ons. Join the community, or take the platform for a spin first.

Andrew Lee Jenkins

Written by

Andrew Lee Jenkins

Andrew Lee Jenkins is the founder of Andrew Lee Jenkins Marketing, a marketing agency in Richmond, Virginia that works with home service companies. He teaches business owners to do their own marketing through The Blueprint and the Growth Unfiltered podcast. He is also the author of You Are the Bottleneck, a guide to building the systems a home service business is missing, and he builds Seedly, the software his own agency runs on.

More about Andrew →