47.66° N 117.43° W Summit · Profitable Growth
Closed-Won to Google Ads in 48 Hours: My Offline Conversion Pipeline
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’s Data Manager API, which replaced the Google Ads API for offline conversion uploads in June 2026. A parallel call lands a matching event in Meta’s 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’s Data Manager API and posts the win against the original gclid. One lands at Meta’s 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 can’t 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 don’t 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 stageis any ofClosed Won. - Filter:
gclidis known ORfbclidis 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
Updated October 2026. This section originally used the Google Ads API’s UploadClickConversions call. Google’s own documentation now says that starting June 15, 2026, UploadClickConversions requests fail if the developer token hasn’t sent offline conversion uploads before, and points everyone to the Data Manager API instead. Integrations that were already uploading kept their access, but any new build should go straight to Data Manager. The payload below is the current one.
Google’s Data Manager API takes the offline conversion at:
POST https://datamanager.googleapis.com/v1/events:ingest
The minimal payload:
{
"destinations": [
{
"operatingAccount": { "accountType": "GOOGLE_ADS", "accountId": "{customer_id}" },
"loginAccount": { "accountType": "GOOGLE_ADS", "accountId": "{customer_or_manager_id}" },
"productDestinationId": "{conversion_action_id}"
}
],
"consent": { "adUserData": "CONSENT_GRANTED", "adPersonalization": "CONSENT_GRANTED" },
"events": [
{
"adIdentifiers": { "gclid": "EAIaIQobChMI..." },
"eventTimestamp": "2026-05-17T14:32:11-07:00",
"conversionValue": 8500.00,
"currency": "USD",
"transactionId": "deal_47821",
"eventSource": "WEB"
}
]
}
What each piece does. operatingAccount is the Google Ads account that owns the conversion action. loginAccount is the account your credentials sign in through (the same ID, or your manager account). productDestinationId is the ID of the “Qualified Lead” or “Closed Won” conversion action, and it has to be an import-from-clicks action (type UPLOAD_CLICKS). gclid comes from the HubSpot deal. eventTimestamp is the deal close time in RFC 3339 format with a timezone offset. conversionValue is the deal amount. transactionId is the HubSpot deal ID, which Google uses to dedupe if the workflow fires twice. eventSource is required. Set the consent block to whatever the person actually agreed to.
Authentication is an OAuth2 access token with the https://www.googleapis.com/auth/datamanager scope, from a Google account that is a user on the advertiser’s Google Ads account. The refresh token lives as an encrypted env var in the Worker, and the Worker exchanges it for a short-lived access token on every call.
While you build, add "validateOnly": true to the request body. Google checks the payload and returns errors without recording anything. Remove it once a test deal validates cleanly.
Layer 4: the Meta CAPI parallel
Meta’s Conversions API takes CRM events at a per-dataset URL. Meta shut down its separate Offline Conversions API in 2025, so offline events now go to the same dataset endpoint as web events:
POST https://graph.facebook.com/{graph_api_version}/{dataset_id}/events
Use the current Graph API version; Meta retires old versions on a schedule.
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.
On Google, adjustments now go through the same Data Manager events:ingest call as the original upload. Send the same destination and conversion action, the same transactionId you used the first time, and a new conversionValue and currency. Google matches on the transactionId and overwrites the recorded value without adding a second conversion. That covers partial refunds cleanly. As of October 2026, Google’s conversion adjustments guide documents value restatements but not a full retraction, so check that page before you build the cancellation path.
Set up a second HubSpot workflow triggered on Deal stage = Closed Lost (after Closed Won) or Deal property "Refunded" = true. Same Worker, same Google endpoint, new value.
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). That 5% band is for a CRM feeding offline imports, where both sides hold the same records, and the wider ad-platform-to-Shopify bands sit on the Google Ads versus Shopify reconciliation.
The report goes to my Monday written summary every week.
What this pipeline is not
It’s not a substitute for clean form-tracking. If the gclid isn’t being captured at form-submit, this pipeline has nothing to send. Run the CLOSE Audit before building the pipeline.
It’s 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’s 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. 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:
- The CLOSE Audit: the 5-pass lead-quality walkthrough. The named diagnostic this pipeline lives at the bottom of. Run the audit before building the implementation.
- I switched a law firm off Max Clicks last quarter. The case study that ran this exact pipeline end-to-end. $14K/mo account, spend cut in half, 38% to 72% qualified-lead share over 90 days.
A shorter version of this pipeline, without the code, is at importing closed-won deals into Google Ads as offline conversions.
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 free custom audit shows where it should plug in, and the implementation is one strategy call after that. Same person on the call as on the keyboard.
More reading
-
I Ran Six AI Search Audits. Crawler Access Was Almost Never the Problem.
3,623 graded AI answers across six businesses. Every AI crawler I tested could read every site. Who got named came down to what other sites said.
-
The Fields You Most Want to Change Are the Ones That Vanish
A Google Ads mutate can return success and change nothing. The standard field mask drops any value equal to its default, and off is a default.
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.