GA4 offline conversions via Measurement Protocol

last verified · against GA4 Measurement Protocol offline-conversion docs as of 2026-07

GA4 offline conversions via the Measurement Protocol: client_id and session_id stitching, transaction_id dedup, 72-hour backdate window, consent, and validation.

What this is

The GA4 Measurement Protocol records offline conversions by sending events directly to Google Analytics servers over HTTP, stitching a later server-side, CRM, or point-of-sale interaction back to the browser session that produced it. Recording offline conversions is a named primary use case; the protocol augments automatic gtag, Tag Manager, and Firebase collection rather than replacing it, and a server-to-server-only setup yields only partial reporting.

Endpoints

Purpose URL
Production (web) https://www.google-analytics.com/mp/collect
Production (EU data) https://region1.google-analytics.com/mp/collect
Validation / debug https://www.google-analytics.com/debug/mp/collect (EU: region1 host)

Events posted to /debug/mp/collect do not appear in reports.

Query parameters (both required)

Parameter Required Source
measurement_id Yes The web data stream’s Measurement ID (starts with G-)
api_secret Yes Admin → Data streams → Measurement Protocol API secrets

Request body — top-level fields

The offline conversion is a POST body of JSON. consent is request-scoped and sits alongside client_id and events.

Field Scope Required Notes
client_id request Yes (web) Joins the offline event to an existing web client/user. Accepts two positive numbers joined by a period (.), or a client-ID cookie value
events[] request Yes Up to 25 events per request
timestamp_micros request or event No Unix timestamp in microseconds, not milliseconds
consent request No ad_user_data and ad_personalization
validation_behavior request No RELAXED (default) or ENFORCE_RECOMMENDATIONS

Event params — offline purchase

Ecommerce fields and session-join fields both travel inside events[].params.

Param Purpose
session_id Joins the event to a specific existing session; also makes the event reflect that session’s geographic/device data
engagement_time_msec Recommended alongside session_id (e.g. for Realtime). Google’s sample includes it; practitioners report it is not strictly required — treat as recommended, not confirmed-required
transaction_id Deduplication key for purchase (see rules below)
value Conversion revenue
currency ISO currency code
items Ecommerce items array

Sending a new session_id value creates a new session without a session_start event — so an absent or mismatched session_id spawns an unintended new session instead of joining the original one.

Identity stitching — capture at the online touchpoint

Persist both signals with the order at the moment of the web interaction, then send them back verbatim on the offline event.

Signal Capture from BigQuery field
client_id The _ga cookie (middle two numbers only) or the gtag get() API user_pseudo_id
session_id The _ga_<MEASUREMENT_ID> cookie or gtag ga_session_id

The _ga cookie is formatted GA1.1.<clientId1>.<clientId2>; the actual client_id is only the middle two dot-separated numbers, not the leading GA1.1 or trailing portion. Note: the session_id to ga_session_id 1:1 mapping, and the need to send session_id (not ga_session_id), is documented by practitioners (Simo Ahava) rather than spelled out on the canonical Measurement Protocol reference page.

Timestamp resolution

Priority order MP uses to resolve the event time:

Priority Source
1 Event-level timestamp_micros
2 Request-level timestamp_micros
3 Time the Measurement Protocol receives the request
Window Value Meaning
Backdating 72 hours Events and user properties can be backdated up to 72 hours
Join receipt 48 hours MP events meant to join/process with Firebase SDK or gtag.js events should be received within 48 hours of the original client-side event timestamp

validation_behavior and out-of-window timestamps

Value Validation Timestamp older than 72h
RELAXED (default) Only rejects malformed requests Accepts the event but overrides its timestamp to 72h ago
ENFORCE_RECOMMENDATIONS Stricter validation on event parameters and timestamps Rejects the event

Confirm the exact per-mode behavior for out-of-window timestamps against the current documentation before relying on it in code.

Key Values Meaning
ad_user_data GRANTED / DENIED Consent to send user data from the request’s events/user properties to Google for advertising
ad_personalization GRANTED / DENIED Consent for personalized advertising

If consent is omitted, GA uses the consent settings from the client’s corresponding online interactions. How that inheritance resolves when there is no matching prior online interaction for a purely offline client_id is not detailed in the docs.

transaction_id deduplication rules

Rule Detail
Mechanism GA deduplicates purchase events that share the same transaction_id
Uniqueness Must be unique per order; must not be reused across different users
Stream scope Works only for data collected through web streams, not app streams
Empty string Never send transaction_id="" — GA deduplicates ALL empty-string purchases together, drastically undercounting

The docs do not publish an explicit deduplication time window (how far apart two identical IDs can arrive and still dedupe).

Limits

Limit Value
Events per request 25
Parameters per event 25
User properties per request 25
Event / parameter name length 40 characters max
Parameter value length 100 characters max (500 on GA 360)

Validation server response

POST the identical payload to /debug/mp/collect. The response carries a validationMessages array; each item has fieldPath, description, and validationCode.

validationCode
VALUE_INVALID VALUE_REQUIRED
NAME_INVALID NAME_RESERVED
VALUE_OUT_OF_BOUNDS EXCEEDED_MAX_ENTITIES
NAME_DUPLICATED

The validation server does NOT validate api_secret or measurement_id — a request can pass validation yet still silently drop in production if those credentials are wrong.

Gotchas

  1. Offline conversion lands as a new (direct) session, not the original. Cause: the request omitted client_id/session_id or sent a session_id that matches no existing session, and any new session_id creates a fresh session. Fix: capture the exact client_id and session_id at the online touchpoint, persist them with the order, and send both back verbatim.
  2. Purchase appears nowhere despite HTTP 2xx or a passing /debug check. Cause: /mp/collect returns 2xx regardless of a bad api_secret or measurement_id, and the debug server does not check those — or the timestamp_micros is beyond 72h. Fix: triple-check both credentials against the data stream, keep the timestamp inside 72h, and confirm microseconds.
  3. Revenue is double-counted between the browser and the server send. Cause: GA only collapses purchases sharing an identical transaction_id; different, missing, or empty IDs are both counted. Fix: use the same real order ID on both sends and never send transaction_id="".
  4. client_id is rejected or fails to match. Cause: the raw _ga cookie value (GA1.1.<clientId1>.<clientId2>) was used instead of the middle two numbers. Fix: strip the GA1.1. prefix to <clientId1>.<clientId2>, or prefer the gtag get() API to avoid fragile cookie parsing.
  5. Older events silently land with a recent timestamp or drop entirely. Cause: timestamp_micros beyond 72h — RELAXED clamps to ~72h ago, ENFORCE_RECOMMENDATIONS rejects. Fix: send within 72h (ideally received within 48h); do not rely on MP for long-delayed backfills.
  6. Ad exports or audiences omit MP conversions, or consent looks wrong. Cause: consent was omitted or DENIED; when omitted, GA inherits the client’s online consent state, which may not match the offline event. Fix: set request-level consent explicitly to reflect the actual consent.

Quick recipes

capture-ids.js
// At the online touchpoint (checkout / lead form), capture the two identifiers
// GA4 needs to stitch a later offline conversion back to THIS session.
gtag('get', 'G-XXXXXXX', 'client_id', (clientId) => {
// session_id is session-scoped state in the _ga_<MEASUREMENT_ID> cookie
const sessionId = readSessionId('_ga_XXXXXXX');
persistWithOrder({ client_id: clientId, session_id: sessionId });
});
parse-ga-cookie.js
// _ga is formatted "GA1.1.<clientId1>.<clientId2>".
// client_id is ONLY the middle two dot-separated numbers.
function clientIdFromGa(gaCookie) {
// "GA1.1.123456789.1721470000" -> "123456789.1721470000"
return gaCookie.split('.').slice(2).join('.');
}
send-offline-purchase.sh
curl -s "https://www.google-analytics.com/mp/collect?measurement_id=G-XXXXXXX&api_secret=API_SECRET" \
-H "Content-Type: application/json" \
-d '{
"client_id": "123456789.1721470000",
"timestamp_micros": 1721480000000000,
"consent": { "ad_user_data": "GRANTED", "ad_personalization": "GRANTED" },
"events": [{
"name": "purchase",
"params": {
"session_id": "1721470000",
"engagement_time_msec": 1,
"transaction_id": "ORDER-10492",
"currency": "USD",
"value": 129.00,
"items": [{ "item_id": "SKU_88", "item_name": "Annual plan", "price": 129.00, "quantity": 1 }]
}
}]
}'
timestamp-micros.js
// Unix MICROSECONDS, not milliseconds. Stay inside the 72h backdate window.
const conversionMs = orderCompletedAt.getTime(); // epoch milliseconds
const timestamp_micros = conversionMs * 1000; // -> microseconds
const withinWindow = Date.now() - conversionMs < 72 * 3600 * 1000;
validate.sh
curl -s "https://www.google-analytics.com/debug/mp/collect?measurement_id=G-XXXXXXX&api_secret=API_SECRET" \
-H "Content-Type: application/json" -d @payload.json
# pass = {"validationMessages":[]}
# NOTE: this endpoint does NOT check api_secret or measurement_id.

Sources

Official pages these values were read from:

Community references for the _ga cookie format and the session_id join, which the canonical MP pages do not spell out:

Changelog

  • — Initial version, verified against official documentation.

dataLayer

0 events · 0 sent

    • home /
      writing /writing
      guides /guides
      work /#work
      about /about
      colophon /colophon
      toggle analyst mode ctrl+.
      print session receipt /#receipt