Skip to content

Closed-won to Google Ads in 48 hours: the offline-conversion pipeline I run on every service-business account (2026 edition)

LEAD GENERATION
Closed-won to Google Ads in 48 hours: the offline-conversion pipeline I run on every service-business account (2026 edition)
Conner Crowe

Quick Take

The CLOSE Audit names the diagnostic. This is the implementation. When a deal moves to closed-won in HubSpot, a webhook fires within 60 seconds. The webhook hands the original Google click ID, the deal value, and the timestamp to Google Ads’ offline conversion endpoint. A parallel call lands a matching event in Meta’s offline Conversions API using the captured fbclid. Inside 48 hours Smart Bidding has rebid against the new signal. The pipeline is roughly 50 lines of code, costs $0 to host on Cloudflare Workers, and is the difference between an account optimizing for cheap form-fills and an account optimizing for paying customers.

What the pipeline does

The shape is straightforward. A prospect clicks a Google Ads ad. The browser receives a gclid URL parameter. A small script captures the gclid (and fbclid, and UTMs) into a cookie. When the prospect submits the contact form, hidden fields read the cookie and post the click IDs to HubSpot alongside their email and phone. HubSpot stores the click IDs on the contact record and inherits them to any associated deal.

Days, weeks, or months later, the deal moves through stages. Qualified, proposal, negotiation. When it lands on closed-won, a HubSpot workflow fires. The workflow makes two outbound HTTP calls. One lands at Google Ads’ offline conversion endpoint and posts the win against the original gclid. One lands at Meta’s offline Conversions API and posts the same against the fbclid. Both calls include the deal value and a timestamp.

Smart Bidding sees the new conversion within an hour. By 48 hours, it has shifted its model: this kind of buyer (the one who clicked, filled the form, qualified, and eventually paid) is now the signal it optimizes for. The cheap-form-fill cohort gets demoted automatically because it never produces closed-won events.

The pipeline runs continuously. Every closed-won deal becomes a feedback signal. Every refund or cancellation can fire a conversion adjustment that subtracts the signal back out.

Layer 1: gclid + fbclid capture on the contact form

Without this layer the rest of the pipeline cannot work. There has to be a click ID stored against the contact at form-submit time. Most service-business sites never wire this and lose the connection at step one.

The pattern: a small script on every page reads URL parameters on first load, stores them in a 90-day cookie, and exposes them to any form on the site through hidden fields.

<script>
(function() {
  var params = new URLSearchParams(window.location.search);
  var KEYS = ['gclid', 'fbclid', 'utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'];
  KEYS.forEach(function(k) {
    var v = params.get(k);
    if (v) {
      document.cookie = k + '=' + encodeURIComponent(v) + '; max-age=7776000; path=/; SameSite=Lax';
    }
  });
  // Populate hidden form fields on submit
  document.addEventListener('submit', function(e) {
    var form = e.target;
    if (form.tagName !== 'FORM') return;
    KEYS.forEach(function(k) {
      if (form.querySelector('[name="' + k + '"]')) return;
      var v = (document.cookie.match(new RegExp('(^| )' + k + '=([^;]+)')) || [])[2];
      if (v) {
        var input = document.createElement('input');
        input.type = 'hidden';
        input.name = k;
        input.value = decodeURIComponent(v);
        form.appendChild(input);
      }
    });
  }, true);
})();
</script>

Drop this in GTM as a Custom HTML tag firing on All Pages. Then create a HubSpot contact property for each of the seven keys (gclid, fbclid, plus the five UTM fields). Map those properties to the corresponding hidden fields in every form. After the form submits, the click ID lives on the contact forever.

When a contact converts to a deal, set up a HubSpot workflow that copies the click-ID properties from the contact to the deal. This is the join key that everything downstream depends on.

Layer 2: the HubSpot workflow on closed-won

HubSpot Operations Hub Professional or higher gets you a Custom Code action that can fire JavaScript inline. Lower tiers do not have this, but the Webhook action is available on every paid Marketing Hub tier and a small Cloudflare Worker (free for the volume any service business will produce) takes the place of the inline code.

The workflow itself:

  • Trigger: Deal property Deal stage is any of Closed Won.
  • Filter: gclid is known OR fbclid is known. Skip the action for deals with no captured click ID. (Add an internal Slack notification on the skipped path so you can see how much closed-won revenue is going untracked. The number should trend toward zero as more channels capture click IDs.)
  • Action 1: Custom code (Ops Hub Pro+) or Webhook (lower tiers) that fires the Google Ads upload.
  • Action 2: Same pattern for the Meta offline event.

The custom-code payload reads deal properties and the associated contact’s properties, builds two API payloads, and fires them. On lower tiers, the webhook posts a JSON body to a Cloudflare Worker that does the same work.

Layer 3: the Google Ads offline conversion call

Google Ads exposes the offline conversion upload at:

POST https://googleads.googleapis.com/v18/customers/{customer_id}/conversionUploads:uploadClickConversions

The minimal payload:

{
  "conversions": [
    {
      "gclid": "EAIaIQobChMI...",
      "conversionAction": "customers/{customer_id}/conversionActions/{conversion_action_id}",
      "conversionDateTime": "2026-05-17 14:32:11+00:00",
      "conversionValue": 8500.00,
      "currencyCode": "USD"
    }
  ],
  "partialFailure": true
}

Five required fields. gclid from the HubSpot deal. conversionAction is the resource name of the “Qualified Lead” or “Closed Won” conversion you created in Google Ads (find it in the conversion action URL). conversionDateTime is the deal close date in YYYY-MM-DD HH:MM:SS+ZZ:ZZ format with a timezone offset that matches your Google Ads account. conversionValue is the deal amount. currencyCode is the obvious one.

The endpoint requires an OAuth2 access token (refresh-token flow against a Google Cloud project with the Ads API enabled) plus a developer token (from your Google Ads MCC). Both live as encrypted env vars in the Worker. The Worker exchanges the refresh token for a short-lived access token on every call.

Set partialFailure: true so a single bad payload (typo’d gclid, missing field) doesn’t kill the batch.

Layer 4: the Meta CAPI parallel

Meta’s offline event endpoint is similar in shape but lives at a per-dataset URL:

POST https://graph.facebook.com/v18.0/{dataset_id}/events

The minimal payload:

{
  "data": [
    {
      "event_name": "Purchase",
      "event_time": 1747500731,
      "action_source": "system_generated",
      "user_data": {
        "fbc": "fb.1.1747500000.IwAR3...",
        "em": ["sha256_hashed_email_here"],
        "ph": ["sha256_hashed_phone_here"]
      },
      "custom_data": {
        "value": 8500.00,
        "currency": "USD"
      },
      "event_id": "deal_47821"
    }
  ]
}

Use action_source: "system_generated" for offline events sourced from a CRM. The fbc field is the formatted fbclid (the SDK does this for you; if you’re rolling raw, prepend fb.1.{event_time}. to the captured fbclid). Hashed email and phone are SHA-256 lowercased. Include both when you have them. Meta’s match rate on a deal jumps from ~40% with fbc alone to ~75% with fbc + email + phone.

event_id should be a stable unique identifier so Meta dedupes if the workflow fires twice. The HubSpot deal ID is the obvious choice.

Authentication: a long-lived access token tied to the System User on your Meta Business Manager. Treat it like a database password. Encrypted env var only, never in the workflow body or in code commits.

Layer 5: conversion adjustments for refunds and cancellations

A deal that closes won and then refunds three weeks later is still in Smart Bidding’s signal as a closed-won. The fix is to fire a conversion adjustment that subtracts the original conversion when the deal status changes.

Google Ads adjustment endpoint:

POST https://googleads.googleapis.com/v18/customers/{customer_id}/conversionAdjustmentUploads:uploadConversionAdjustments

The payload uses adjustmentType: "RETRACTION" (for full cancellation) or RESTATEMENT (for partial refund). Both reference the original gclid + conversionAction so Google can identify which click conversion is being adjusted.

Set up a second HubSpot workflow triggered on Deal stage = Closed Lost (after Closed Won) or Deal property "Refunded" = true. Same Worker, different endpoint, different adjustment type.

Meta handles this with a delete event of the same event_id. Cleaner than Google’s adjustment API, less verbose.

Layer 6: the weekly reconciliation report

The pipeline breaks for predictable reasons without alerting you: a workflow gets disabled, an API token expires, a Google Ads conversion action gets archived. Without a reconciliation report, you find out three months later when Smart Bidding’s signal has drifted.

The report runs every Monday morning. Two queries:

  • Closed-won deals in HubSpot for the prior 7 days, by source
  • Offline conversions received in Google Ads + Meta for the same 7 days, by conversion action

Drift target: under 5% mismatch. If the gap opens wider than 5%, something is broken in the pipeline. Anything under 5% is normal floor noise (clicks beyond Google’s 90-day lookback, deals from organic that have no click ID, manually-entered deals with no source).

The report goes to my Monday written summary every week.

What this pipeline is not

It is not a substitute for clean form-tracking. If the gclid is not being captured at form-submit, this pipeline has nothing to send. Run the CLOSE Audit before building the pipeline.

It is not a substitute for the qualified-lead signal swap. If “form submission” is still marked as primary in Google Ads, the closed-won feedback just adds another signal alongside the noisy one. The CLOSE Audit’s S pass (Signal swap) has to land first.

It is not a HubSpot-only pattern. Salesforce, Pipedrive, Zoho, GoHighLevel all have webhook surfaces and the same workflow logic applies. The endpoints and payload shapes for Google Ads and Meta are identical regardless of CRM.

The receipts

Wired this pipeline on the law-firm rebuild documented here. HubSpot Marketing Hub Starter (no Ops Hub) plus a Cloudflare Worker. ~150 lines of TypeScript including auth, error handling, and the reconciliation query. Cloudflare cost: $0/mo at the volume the firm produced (under 100K requests). Hosted under the firm’s own Cloudflare account so the auth tokens stay with them.

Deal-to-Google-Ads latency: 45-90 seconds (HubSpot workflow trigger lag + Worker execution time + Google Ads API processing). Deal-to-Smart-Bidding-signal: 24-48 hours (Google Ads aggregates offline conversions into Smart Bidding’s model on a daily cadence).

Match rate, Google Ads (offline conversion accepted / offline conversion sent): 87% after 60 days of reconciliation cleanup, mostly capped by stale gclids beyond the 90-day lookback.

Match rate, Meta (fbc + hashed email + phone): 71%, lower because not every closed-won deal had clicked from Meta to begin with.

Keep going

If this hit, the next two pieces in the same universe:

Free PDF: The 25-page Lead Quality Stack. The architecture this implementation slots into.

If your CRM has closed-won data sitting unused by your ad platforms, the implementation is one audit call away. Same person on the call as on the keyboard.

Want a review like this on your account?

Want this kind of review
on your account?

Thirty minutes on the phone. Same person on the call as on the work. Walk out with a clear set of next steps.

Book a Call