Analytics · engineers · PMs
timestamp_micros is 16 digits
Date.now() is 13 digits. GA4 wants microseconds. Pixellint flags timestamp_micros that are not 16 digits because milliseconds survive into a 1970-adjacent or far-future hit more often than people believe.
Identity of the stream
Measurement Protocol is a POST to google-analytics.com/mp/collect (regional hosts too) with measurement_id and api_secret on the query string. api_secret is required. The stream identity is exactly one of measurement_id in G- form, or firebase_app_id for an app stream. Neither is a missing destination. Both at once is ambiguous: vendor.google-analytics.stream.identifier_ambiguous. A UA- leftover in measurement_id fails the G- pattern.
Web events join on client_id (the _ga cookie's client id, or a stable id you mint). App streams join on app_instance_id and firebase_app_id. Sending a web client_id at an app stream, or both stream identifiers on one request, is how hits land on nobody. Pixellint flags a missing client_id as vendor.google-analytics.body.client_id.missing because Google will take the HTTP and still not join a user. Warnings do not fail pixellint validate; this one should fail your run.
POST /mp/collect?measurement_id=G-XXXXXXX&api_secret=SECRET HTTP/1.1
Host: www.google-analytics.com
Content-Type: application/json
{"client_id":"1234567890.1234567890","timestamp_micros":1770000000000000,"events":[{"name":"page_view"}]}
The body that actually counts
events[] is the payload. An empty array is identifiable as GA4 and sends nothing; Pixellint flags vendor.google-analytics.body.events.empty. A body with no events key at all is not claimed as MP. Each event needs a name. Google documents names as 40 characters or fewer, letters, digits, and underscores, starting with a letter. Longer names warn as vendor.google-analytics.body.name.invalid. A misspelled recommended name still 204s.
params.value requires params.currency (ISO 4217, three letters). Value without currency is dropped revenue: vendor.google-analytics.body.value_requires_currency. non_personalized_ads is deprecated in favor of the consent object. The pack warns vendor.google-analytics.body.non_personalized_ads.deprecated. It does not invent a replacement value. Put ad_user_data and ad_personalization on consent, not a boolean leftover from a 2021 snippet.
Ecommerce fields are not optional decoration
A purchase needs currency, value, transaction_id, and items. Without them you have a named event with no money. Pixellint: vendor.google-analytics.body.purchase_requires_ecommerce_fields. refund needs currency, value, and transaction_id (items optional on a full refund). add_to_cart and begin_checkout need currency, value, and items. A dollar sign is not a currency code. 19,99 is not a JSON number.
transaction_id is how Google dedupes purchase. Retry the same order with a new id and you double-count revenue. Omit it and two POSTs of the same cart look like two sales. Mint it from the order id, persist it, replay it. Session reporting wants session_id and engagement_time_msec on the event params; without engagement_time_msec, MP hits often never appear in standard reports even when collect 204d.
{
"client_id": "1234567890.1234567890",
"timestamp_micros": 1770000000000000,
"events": [{
"name": "purchase",
"params": {
"currency": "USD",
"value": 19.99,
"transaction_id": "T12345",
"session_id": 1724260000,
"engagement_time_msec": 100,
"items": [{"item_id": "sku-1", "item_name": "Widget", "price": 19.99, "quantity": 1}]
}
}]
}
Microseconds, not Date.now()
timestamp_micros is a Unix instant in microseconds, exactly 16 digits. Date.now() is milliseconds, 13 digits. Multiply by 1000. A 10-digit seconds value needs 1_000_000. Pixellint: vendor.google-analytics.body.timestamp_micros.invalid. Meta CAPI is the inverse clock (seconds). Copying a CAPI helper into MP is how you mint a 13-digit micros field that Google will not treat as now.
Omit timestamp_micros and Google stamps received-at. That is fine for a live purchase. It is fatal for a queue that drains Friday's orders on Monday. Store the order time, convert to microseconds, send that. DebugView will not tell you the clock was wrong if you are looking at today's test device.
Debug endpoint versus production, and the collect pack
Production MP returns 204 and stores a misspelled event name. The debug endpoint is /debug/mp/collect. It validates. It is not a second property. Do not point production traffic at the debug URL. debug_mode on the event is for DebugView. Leave it on and you keep a debug stream in a UI nobody watches. Measurement Protocol and the browser collect hit are different packs.
The Google tag fires /g/collect, not /mp/collect. That pack is vendor/google-analytics-collect, source_level ecosystem_reference, because Google documents the tag and MP but not this query format. Contract on the wire: v=2, tid in G- form, cid, en. v=1 is Universal Analytics. A UA- tid is a stale property. Both still 200. pixellint validate url on the collect hit, pixellint validate json on the MP body. Pixellint is not affiliated with Google.
pixellint validate json @mp.json --rulepack vendor/google-analytics
pixellint validate url 'https://www.google-analytics.com/g/collect?v=2&tid=G-XXXXXXX&cid=123.456&en=page_view' --rulepack vendor/google-analytics-collect
Check the artifact
Paste the pixel URL or JSON body into the
playground. Same engine as
pixellint validate. Nothing leaves the tab.