All posts
Performance Ads

Offline Conversion Tracking: CRM to Google Ads

Set up offline conversion tracking in Google Ads: capture the gclid at booking, link it in your CRM, upload the conversion when they show up.

August 1, 2026ยท11 min readยทby Olexander Cheberko
Table of contentstap to expand

Google Ads can see which click led to a booking. It has no idea which booking turned into a customer who actually walked in. That blind spot is where most local and lead-gen ad budgets quietly leak, because the platform keeps optimizing toward the cheap action it can measure, the form-fill, instead of the expensive one that pays the bills. Offline conversion tracking closes the loop: capture the ad click id at booking, carry it through the CRM, and report the conversion back to Google Ads only when the person shows up.

I built exactly this loop for a multi-location US medical practice running Google Ads for calls and online bookings. The interesting part was not the upload to Google. It was doing all of it without touching a single field of patient data and without ever putting the booking flow at risk. Below is the full path from click to real customer, with the three gotchas that cost the most time so you can skip them.

Quick answer

The loop has five steps, and each has one rule that keeps it safe:

  1. Capture the click id (gclid) on the site when the booking is submitted. Read it from the URL or the ad cookie.
  2. Attach it to the booking without breaking the booking. If the form endpoint rejects extra fields, send the gclid in a separate HTTP header, not the body, and fail open.
  3. Link it to the record in the CRM or scheduling system, keyed to the appointment or order id.
  4. Fire the conversion on arrived or completed, not on booked. The booking is a lead. The arrival is the conversion.
  5. Upload it to Google Ads through the API with a click id, a value, and a timestamp. Nothing else needs to leave.

If you only remember one line: never risk the booking to save a marketing tag, and never count a lead as a customer.

Why do online conversions overstate what worked?

A standard Google Ads pixel fires when the browser reaches a thank-you page. That tells you a form was submitted. It says nothing about whether that person became a paying customer, and in most appointment or quote businesses the gap between those two events is enormous. People book and never come. They pick a competitor. They no-show. In the engagement above, like a lot of appointment-based businesses, only roughly half of the bookings turned into an arrival.

If Smart Bidding is chasing the form-fill, it will happily buy you more of the clicks that book and vanish, because from the pixel's point of view those are wins. The fix is not a better pixel. It is a second, truer conversion that fires later in the real world. That is the entire job of offline conversion tracking, and it is the piece most accounts are missing. If you want the full picture of what proper Google Ads conversion tracking should measure, this is the layer that separates leads from customers.

How does the full loop work, click to arrival?

Here is the shape of the whole thing before the details. Each stage hands one small, safe token to the next.

StageWhat happensWhat is stored or sent
Ad clickGoogle appends a gclid to the landing URLgclid held in a first-party cookie
Booking submitSite sends the gclid alongside the bookinggclid in a separate HTTP header
Backend listenerReads the header, ignores it if malformedgclid mapped to the new appointment id
Attribution storeIsolated table, keyed to appointment idgclid, value, status, timestamps, no PHI
Status changeStaff marks the visit arrived or completedstatus flips to a conversion-eligible state
UploadJob sends the conversion to Google Adsclick id, value, currency, timestamp

Notice what is not in that last column: no name, no email, no diagnosis, no address. Only an anonymous click id, a value, and a timestamp ever leave the system. That is what makes it defensible in a HIPAA-conscious environment, and it is a design choice you make on purpose, not a happy accident.

Ad click
gclid in a cookie
Booking submit
gclid in a header
Attribution store
keyed to appointment
Status change
arrived or completed
Upload
click id + value + time
Each stage hands one small, safe token to the next. Only a click id, a value, and a timestamp ever leave.

How do I capture the gclid without breaking the booking form?

This is the gotcha that eats an afternoon if you have not hit it before. The obvious plan is to add a gclid field to the booking form's JSON body and let the backend save it. On many booking systems that plan fails hard: the form endpoint validates its schema strictly and rejects any unknown field with an HTTP 400. So the moment you smuggle in a marketing field, real bookings start failing. You have traded a tracking gap for a revenue outage. That is a bad trade every single time.

The clean solution is to keep the gclid out of the body entirely and send it in a separate HTTP header instead. The booking payload stays exactly as the endpoint expects it, so validation passes untouched. A backend listener reads the header out of band and does the attribution work on its own, with zero influence on whether the booking succeeds.

// On the site, at booking submit.
// The body is EXACTLY what the booking API expects. Nothing added.
// The gclid rides along in a header, where the strict schema never looks.
const gclid = getCookie("gclid"); // first-party cookie set on landing
 
fetch("/api/booking", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    ...(gclid ? { "X-Attribution-Gclid": gclid } : {}),
  },
  body: JSON.stringify(bookingPayload), // untouched, passes validation
});

The rule that matters most here is fail-open. The attribution path must never be able to break the booking. If the header is absent, empty, or malformed, the listener shrugs and the booking proceeds normally.

// Backend listener, running beside the booking handler.
function captureAttribution(req, appointmentId) {
  try {
    const gclid = req.headers["x-attribution-gclid"];
    // Validate shape; a real gclid is a bounded, URL-safe token.
    if (!gclid || !/^[A-Za-z0-9_-]{10,200}$/.test(gclid)) return;
 
    saveAttribution({ appointmentId, gclid, capturedAt: new Date() });
  } catch (err) {
    // Swallow everything. A tracking failure must never surface to the booking.
    logQuietly("attribution capture skipped", err);
  }
}

Wrap the whole thing in a try/catch that swallows errors and logs them quietly. A missed conversion is an annoyance you can backfill. A failed booking is lost revenue and a lost patient. The marketing tag is always the thing that gives way, never the booking flow.

Where should I store the attribution data?

Put it in its own isolated table, keyed to the appointment or order id, and nowhere near sensitive records. This is a HIPAA point and an engineering point at the same time, and they happen to agree.

The attribution table holds only what the upload needs: the appointment id, the gclid, a value, a status, and a couple of timestamps. It carries no patient name, no contact details, no clinical anything. Critically, it does not hold a foreign key into the patient or medical tables. It references the appointment id as a plain value, so the marketing data is additive and can be dropped at any time without touching a single row of the real system.

CREATE TABLE ad_attribution (
  id            BIGSERIAL PRIMARY KEY,
  appointment_id BIGINT NOT NULL,      -- plain reference, NOT a FK into PHI
  gclid         TEXT   NOT NULL,
  value         NUMERIC(10,2),         -- assigned when status becomes eligible
  currency      TEXT   DEFAULT 'USD',
  status        TEXT   DEFAULT 'booked', -- booked -> arrived -> uploaded
  captured_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  uploaded_at   TIMESTAMPTZ
);
-- No name, no email, no phone, no diagnosis. Ever.

Designing it this way buys you two things. First, it is trivially reversible: DROP TABLE ad_attribution removes the entire feature and the core booking system does not notice. Second, it shrinks your compliance surface to almost nothing, because there is simply no protected health information in here to leak. If a phone number is ever needed as a fallback match key when a gclid is missing, hash it with SHA-256 before it is stored or sent, so the raw number never lives in this table either. Keeping the attribution store additive and rollback-safe is the difference between a change you can ship on a Friday and one that keeps you up at night.

When should the conversion actually fire?

On arrived or completed. Not on booked. This is the single most valuable decision in the whole build, and it is the one most setups get wrong.

A booking is a lead. It is a promise that someone might show up. The conversion, the event you actually want Google Ads to buy more of, is the arrival. When you fire on booked, you teach Smart Bidding to chase people who fill out forms, and a large share of those never become customers. When you fire on arrived, you teach it to chase clicks that turn into people standing at the front desk.

The mechanics are simple. When staff mark a visit as arrived or completed in the scheduling system, flip the attribution row's status and assign the value, then let the uploader pick it up.

-- Triggered when the appointment status changes to arrived/completed.
UPDATE ad_attribution
   SET status = 'arrived',
       value  = 180.00        -- your average or actual value for that visit type
 WHERE appointment_id = $1
   AND status = 'booked';

Assign a value that reflects the visit, even a conservative average per appointment type. Google Ads optimizes far better toward value than toward a flat count, and a rough, honest number beats a precise one you cannot defend. Getting the fire-on-arrival logic and the values right is most of the work in a real offline conversion tracking setup, and it is exactly where a generic pixel install stops short.

How do I upload the conversion back to Google Ads?

Once a row hits arrived, a small job sends it to Google Ads as an offline click conversion. You are matching on the gclid you captured at booking, so no personal identifier is required for the match at all. The payload is deliberately thin.

{
  "conversions": [
    {
      "gclid": "GCLID_CAPTURED_AT_BOOKING",
      "conversion_action": "customers/CUSTOMER_ID/conversionActions/CONVERSION_ACTION_ID",
      "conversion_date_time": "2026-08-01 14:32:00-04:00",
      "conversion_value": 180.00,
      "currency_code": "USD"
    }
  ],
  "partial_failure": true
}

A few field-tested notes. Set partial_failure to true so one bad row does not sink the whole batch; you log the rejects and retry them, and the good conversions still land. The conversion_date_time has to include a timezone offset or the API rejects it, and it must fall inside the conversion action's click-through window, which is another reason firing on the real arrival date matters. After upload, stamp uploaded_at so the job never double-counts a conversion on its next run.

The upload itself runs on a schedule, not in the booking request. Batching it means the customer-facing flow never waits on Google, and a Google API hiccup at 2am cannot ripple back into anything a patient touches. The two systems stay fully decoupled, which is the whole point.

What does this change about how you read Google Ads?

By platform count
By tagged bookings
By paid visits
Schematic on synthetic data, proportions typical: one spend, three denominators. The platform count flatters you, and only the last is about money.

Once arrivals flow back in, the account tells a different and more honest story. Campaigns that looked cheap on form-fills often turn out to book no-shows, while a campaign you were about to cut turns out to bring in the people who actually arrive and pay. You can finally let Smart Bidding optimize toward the arrival value and watch cost-per-real-customer become a number you trust instead of a guess.

If you are running ads for a business where the click and the sale are days and a human decision apart, this is the highest-leverage tracking work you can do, and it is the core of what I build. I am a freelance engineer who wires the ads, the CRM, and the attribution together as one system, so the loop actually holds when a token expires or a form endpoint changes underneath you. If you want to see which of your clicks become customers instead of just leads, send me how your booking flow works today and I will tell you whether it is a week of work or a proper project before you spend a dollar.

Tags

offline conversion trackinggoogle adsconversion trackingcrmattributiongclid

Frequently asked questions

What is offline conversion tracking in Google Ads?

It is a way to send conversions that happen after the click, outside the browser, back to Google Ads. You capture the ad click id at booking, store it against the record in your CRM, and upload the conversion once the customer actually shows up. Google Ads then optimizes toward real customers instead of raw form-fills.

How do I send CRM conversions back to Google Ads?

You need three things per conversion: the gclid captured at the moment of the ad-driven action, a value, and a timestamp. Your CRM or a small backend job uploads those to Google Ads through the API when the record reaches the stage that counts, such as arrived or completed. No browser or pixel is involved at upload time.

How do I capture the gclid without breaking the booking form?

Do not add it to the form body if the form endpoint rejects unknown fields, because that returns an HTTP 400 and the booking fails. Send the gclid in a separate HTTP header and read it with a backend listener. Make it fail-open: if the header is missing or malformed, the booking proceeds normally and nothing breaks.

Should the conversion fire when someone books or when they show up?

When they show up. A booking is a lead, not a sale. Fire the conversion on the arrived or completed status so Google Ads learns which clicks turn into people who actually walk in, not people who book and vanish. In many businesses only roughly half of bookings arrive, so this changes the numbers a lot.

Is offline conversion tracking HIPAA-safe for a medical practice?

It can be, if you design it that way. In a HIPAA-conscious build, only an anonymous click id, a value, and a timestamp ever leave your system. No patient data is persisted for marketing, and if a phone number is used as a fallback match key it is hashed with SHA-256 before it leaves. The attribution data lives in its own table with no link into clinical records.

How long does it take offline conversions to show up in Google Ads?

Uploads are usually processed within a few hours, but Google Ads needs a window of conversions before Smart Bidding reacts. The bigger clock is your own funnel: if a customer books today and arrives next week, that conversion cannot upload until next week. Plan for the reporting lag your sales cycle creates.

Need something like this built?

Free 15-min discovery call. I'll listen, ask honest questions, and tell you if I can help.

More in Performance Ads