A lead fills in a form on your site. Three days later, a sales rep closes the deal on the phone. Or a customer reserves online and pays in store. The money is real, but GA4 never sees it, so your reports, your audiences and your key events stop at the form submission.

GA4 gives you two official ways to send those offline conversions back: a CSV file (uploaded by hand or pulled from SFTP) and the Measurement Protocol, an HTTP API. Both work. Both fail quietly if you skip one detail. This is the short, step-by-step version.

Before you start: which method?

Everything below comes from Google’s own documentation (links at the end). Here is how the two options compare:

Event data import (CSV / SFTP) Measurement Protocol
Where Admin > Data collection and modification > Data import HTTP POST from your server
Who builds it Anyone who can export a CSV from the CRM A developer
How late can the event be Within the previous 2 calendar days, plus today Backdated up to 72 hours; received within 48 hours of the original online event if you want it processed with it (for example for attribution)
Delay before reports Up to 24 hours Realtime within seconds
Main limits 1 GB per file, 120 uploads per property per day 25 events per request, body under 130 kB

Look at the time window. GA4 offline conversions are not a “send everything at the end of the month” feature. Both methods expect the offline event to arrive within a few days. If your sales cycle is longer than that, you will still get the event in GA4, but don’t expect it to be linked back to the original online session the way you hope.

Step 1: Capture the client_id at the online moment

This is the step that decides whether the whole project works. GA4 recognises a web visitor through the client_id (for an app, the app_instance_id). Google’s Measurement Protocol documentation is explicit: events are joined with online interactions using client_id or app_instance_id. That join is what brings in geography, device, privacy settings and advertising identifiers such as GBRAID/WBRAID.

So when the lead or the order is created online, read the client_id from the Google tag and store it in your CRM or back office, next to the lead or order number. The official way to read it is the gtag get command:

gtag('get', 'G-XXXXXXXXXX', 'client_id', (clientId) => {
  // send clientId to your backend with the form or order
});
gtag('get', 'G-XXXXXXXXXX', 'session_id', (sessionId) => {
  // optional: keep it too (see step 4)
});

Also store the user_id if you have a login, and the order or transaction number. In my audits, this is where most offline conversion projects die: the CRM has the email and the deal amount, but no client_id. Without it, there is nothing to connect the offline event to.

One rule: no personal data. The Import events page says you may not upload or associate personally identifiable information with event import. An email or phone number does not go in the file or the payload.

Step 2: Prepare GA4 to receive the event

Do this before the first upload, not after.

  1. Pick the event name. Use a recommended event when one fits (for example purchase, or close_convert_lead from the recommended lead events), otherwise a custom name. Names are limited to 40 characters, letters, numbers and underscores, starting with a letter.
  2. Check the reserved names. Events such as session_start, first_visit or user_engagement, and prefixes such as ga_, google_ and firebase_, are reserved. Data that uses them is not uploaded.
  3. Mark it as a key event. In Admin > Data display > Events, click Create event, enter the name, turn on “Mark as key event” and choose “Create with code”. You need the Marketer role or above.
  4. Create the custom dimensions. If you send parameters that don’t exist yet (deal stage, store ID, sales channel), create them as custom dimensions first. Google says it can take up to 4 hours before Analytics recognises the imported data for those dimensions.

Step 3, option A: Import the events with a CSV file

3A.1 Build the file

For a web stream, the required columns are measurement_id, client_id and event_name. For an app, use firebase_app_id and app_instance_id instead. Everything else is optional:

A minimal file for two offline sales:

measurement_id,client_id,user_id,timestamp_micros,ad_user_data,ad_personalization,event_name,event_param.transaction_id,event_param.value,event_param.currency
G-12345ABCDE,1234567890.0987654321,1000001,1761904800000000,granted,granted,purchase,ORD-20481,1250.00,EUR
G-12345ABCDE,2345678901.9876543210,,1761908400000000,granted,denied,purchase,ORD-20482,480.00,EUR

Two traps here. First, don’t put the same column twice: Google warns against files with duplicate keys, such as two fields named user_id. Second, every purchase needs a real, unique transaction_id (more on that in the errors section).

3A.2 Upload it

  1. Admin > Data collection and modification > Data import. You need the Editor role or above.
  2. Click Create import source and give it a name.
  3. Choose the data type Events data. It is a collection/processing-time import: the data is processed as if it had been collected with the tag.
  4. In “Import source”, choose Manual CSV upload, or SFTP if you want GA4 to fetch the file on a daily, weekly or monthly schedule.
  5. Click Next, check the field mapping, then Import.

For the next uploads, use Import now on the same import source. The new file must contain the same fields as the original, or a subset of them. If you need different columns, Google says you have to delete the source and create a new one.

If you go with SFTP, read the prerequisites before you start: the server must support the ssh-rsa or ssh-dss host-key algorithms, you must authorise the public key shown in the import source details, and a badly formatted URL fails with an “internal error” message. The About Data Import page gives the exact URL formats and the IP ranges to allowlist.

Step 3, option B: Send the events with the Measurement Protocol

3B.1 Create an API secret

Admin > Data collection and modification > Data streams > your web stream > Measurement Protocol API secrets > Create. Keep it on the server. Google is clear that exposing the api_secret in client-side code lets anyone send spam data to your property.

3B.2 Validate the payload first

The Measurement Protocol does not return an error code when the payload is malformed. You get a 2xx even if GA4 drops the event. So test against the validation server, which uses the same request with /debug in the path, and set validation_behavior to ENFORCE_RECOMMENDATIONS while testing:

curl -X POST "https://www.google-analytics.com/debug/mp/collect?measurement_id=G-12345ABCDE&api_secret=YOUR_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "client_id": "1234567890.0987654321",
    "user_id": "1000001",
    "timestamp_micros": 1761904800000000,
    "validation_behavior": "ENFORCE_RECOMMENDATIONS",
    "consent": { "ad_user_data": "GRANTED", "ad_personalization": "GRANTED" },
    "events": [{
      "name": "purchase",
      "params": {
        "transaction_id": "ORD-20481",
        "value": 1250.00,
        "currency": "EUR",
        "session_id": "1761901200",
        "engagement_time_msec": 100
      }
    }]
  }'

An empty validationMessages array means the structure is fine. Note what the validator does not check: the api_secret. And events sent to the validation server never show up in reports.

3B.3 Send to production

Same request on /mp/collect, without validation_behavior (Google recommends leaving it unset in production so fewer events get rejected). Keep these rules from the documentation in mind:

Google also states that the Measurement Protocol is meant to supplement the tag, not replace it. Sent alone, with made-up client_ids, you only get partial reporting.

Step 4: Check that it arrived

Common errors and how to spot them in GA4

The client_id is invented

Some setups generate a random ID on the server because the real one was never stored. GA4 accepts the event, but it can’t join it with the online activity, so you lose the geography, device and privacy settings that come from the tag. How I check it in audits: in an exploration, put the offline event against Country or Device category. If almost everything lands in “(not set)” or a single default value, the IDs probably don’t match real visitors. Fix it at step 1.

The event is too late

CSV rows must be within the previous 2 calendar days plus today. Measurement Protocol events older than 72 hours get their timestamp moved to 72 hours ago. How to check: compare the dates of the offline event in a GA4 report with the dates in your CRM export. If a whole week of deals shows up on one day, your batch is running too late.

The purchase is counted twice, or not at all

GA4 deduplicates purchases that share the same transaction_id, on web streams only. Two consequences from Google’s page: a reused ID across different orders makes you undercount, and an empty string (transaction_id="") makes GA4 deduplicate all purchases that carry it. How to check: build an exploration with Transaction ID and Purchases. Each ID should appear once, and “(not set)” or empty should not.

On deduplication: the official pages I used for this article (Import events, About Data Import, the Measurement Protocol guides) don’t describe any event_id merge key. The deduplication Google documents for GA4 is the transaction_id one above. If your design depends on another key, test it on a few events before you rely on it.

“200 OK” but nothing in GA4

The Measurement Protocol answers 2xx even for malformed payloads. Usual causes: a reserved event or parameter name, a name with a hyphen or a space, a wrong api_secret (the validator doesn’t check it), or a measurement_id from another stream. How to check: run the payload through /debug/mp/collect, then send one test with debug_mode and watch DebugView.

The custom dimension is empty

The parameter was imported before the custom dimension existed, or less than 4 hours after it was created. How to check: open an exploration and see if the dimension is available in the picker, as Google suggests.

You expect the old data to update

Event data import is joined at collection/processing time. It is not joined with data GA4 has already processed. And deleting an import source does not delete the uploaded data: you need a data deletion request for that. Test on a small file first.

Conclusion

GA4 offline conversions come down to three things: store the client_id when the visitor is online, send the event back within the time window (a few days at most), and check it in Realtime, DebugView or an exploration instead of trusting a 200 response. CSV import suits a weekly routine run by the sales or CRM team. The Measurement Protocol suits a real pipeline your back office triggers as soon as the deal closes.

If your GA4 data doesn’t match your CRM and you can’t tell where the gap comes from, that is exactly what I check in a tracking audit.

Related reading

Sources