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.
- Pick the event name. Use a recommended event when one fits (for example
purchase, orclose_convert_leadfrom the recommended lead events), otherwise a custom name. Names are limited to 40 characters, letters, numbers and underscores, starting with a letter. - Check the reserved names. Events such as
session_start,first_visitoruser_engagement, and prefixes such asga_,google_andfirebase_, are reserved. Data that uses them is not uploaded. - 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.
- 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:
timestamp_micros: when the event happened, in microseconds. It must fall within the previous 2 calendar days plus today. Without it, GA4 uses the upload time.user_id: optional, recommended when available.- Consent columns such as
ad_user_dataandad_personalization: optional, highly recommended by Google. event_param.<name>for any parameter,user_property.<name>for user properties,item1.item_id,item1.priceand so on for items.
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
- Admin > Data collection and modification > Data import. You need the Editor role or above.
- Click Create import source and give it a name.
- 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.
- In “Import source”, choose Manual CSV upload, or SFTP if you want GA4 to fetch the file on a daily, weekly or monthly schedule.
- 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:
- A
timestamp_microsolder than 72 hours is not rejected by default: GA4 accepts the event and moves its timestamp to 72 hours ago. WithENFORCE_RECOMMENDATIONS, it is rejected. - To be processed together with the online event, for example for attribution, the offline event should reach GA4 within 48 hours of the original client-side event.
- If you want the event tied to a specific session (and its geography and device), include the
session_idand send it within 24 hours of the start of that session. session_idandengagement_time_msecare what make the event visible in Realtime.- Limits: 25 events per request, 25 parameters per event, parameter values of 100 characters (500 on GA4 360), body under 130 kB.
- Rules you set up in the GA4 interface to create or modify events are not applied to Measurement Protocol events. If you rename events with a rule, do the same in your code.
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
- Measurement Protocol: open Reports > Realtime and look at the “Event count by Event name” and “Key events by Event name” cards. Google says events typically show up within a few seconds. For more detail, add
"debug_mode": trueand a positiveengagement_time_msecto a test event and watch it in Admin > Data display > DebugView. - CSV import: nothing in Realtime. Allow up to 24 hours, then check an exploration with Event name and your custom dimensions. In the import source details you can see the upload history. The “% imported” and “match rate” columns don’t apply to event data import, so don’t wait for them.
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
- Why GA4, SFCC and Offline Orders Will Never Match — And What That Gap Actually Tells You
- The Transaction ID Problem: A Complete Diagnostic Guide to Fixing Your GA4/SFCC Join
- Dark Traffic and Orphan Orders: The Complete Classification, Measurement and Exploitation Guide
Sources
- Google Analytics Help: Import events
- Google Analytics Help: About Data Import
- Google Analytics Help: Minimize duplicate key events with transaction IDs
- Google Analytics Help: Create or modify key events
- Google Analytics Help: Create event-scoped custom dimensions
- Google Analytics Help: Recommended events
- Google Analytics Help: Monitor events in DebugView
- Google for Developers: Measurement Protocol overview
- Google for Developers: Send Measurement Protocol events
- Google for Developers: Validate events
- Google for Developers: Verify implementation
- Google for Developers: Measurement Protocol reference
- Google for Developers: gtag.js API reference