Appendix D·Appendix
Admin and Data API Reference
This appendix is a working reference for the Google Analytics Admin API, the Data API, and the Measurement Protocol calls that the audit and reconstruction of GA4 property 534525683 used. Every call here was run against the live property in Google Cloud project gen-lang-client-0444184725. The narrative that explains why each call mattered lives in Chapter 4, Auditing Methodology (Programmatic and Adversarial), and Chapter 6, The Server-Side Measurement Protocol; this appendix supplies the mechanics so a practitioner can copy, adapt, and run them. The service account is written id-01-{company}@gen-lang-client-0444184725.iam.gserviceaccount.com throughout.
D.1 Authentication: minting an impersonated token
The audit never downloaded a service-account key file. Instead the operator's own Google identity, granted the Service Account Token Creator role on the target service account, mints a short-lived OAuth access token by impersonation. A key file that never exists cannot leak.
# Mint a scoped, short-lived token by impersonating the service account.
TOKEN=$(gcloud auth print-access-token \
--impersonate-service-account="id-01-{company}@gen-lang-client-0444184725.iam.gserviceaccount.com" \
--scopes="https://www.googleapis.com/auth/analytics.edit")
# Use it as a Bearer token on any Admin or Data API request.
curl -s -H "Authorization: Bearer ${TOKEN}" \
"https://analyticsadmin.googleapis.com/v1beta/properties/534525683"
The --scopes flag is load-bearing. Google's OAuth model grants a token exactly the scopes you name and nothing else, and several GA4 endpoints reject a token that lacks the precise scope even when the underlying GA4 role would allow the action. Mint a new token per scope family rather than requesting a broad scope you do not need.
| Task | Scope to request | Notes |
|---|---|---|
| Read config (properties, streams, dimensions, key events, settings) | analytics.readonly | Sufficient for every Admin API GET and for the Data API. |
| Write config (create dimensions, key events, patch retention and Enhanced Measurement) | analytics.edit | Covers all Phase 0 and Phase 1 writes. |
Read accessBindings (who has access to the property) | analytics.manage.users.readonly | A token minted only with analytics.edit returns ACCESS_TOKEN_SCOPE_INSUFFICIENT on this endpoint. The scope is distinct from the GA4 role; see Chapter 15, Access and Permissions Model. |
A single token can carry several scopes if you pass a comma-separated list, but the audit deliberately kept the write scope out of read-only sessions so a mistaken PATCH could not fire from a session provisioned for inspection.
D.2 Admin API reference
Base host: analyticsadmin.googleapis.com. Most resources are stable on v1beta; Enhanced Measurement settings are only exposed on v1alpha, noted per row. The property is 534525683; the web data stream is 14548640408. All paths below are appended to https://<host>/<version>/.
| Method | Path | Purpose |
|---|---|---|
| GET | accountSummaries | Enumerate accounts and their properties to locate properties/534525683 and its display name without knowing the account ID in advance. |
| GET | properties/534525683 | Read property-level config: timeZone (America/Los_Angeles), currencyCode (USD), industryCategory (HOME_AND_GARDEN), createTime (2026-04-24). |
| GET | properties/534525683/dataStreams | List streams; returns the web stream 14548640408 with its measurementId G-WH410Z73V1. |
| GET | properties/534525683/customDimensions | List registered custom dimensions. The audit found eight: cta_location, session_source (USER scope), page_type, job_slug, location_slug, service_slug, form_source, pest_slug. |
| POST | properties/534525683/customDimensions | Create a custom dimension. Body: parameterName, displayName, scope. Used to register service_type, cta_label, and error_message. |
| GET | properties/534525683/keyEvents | List key events. Baseline: purchase, contact_form_submit, career_application_submit, cta_click, quote_form_submit, call_button_click. |
| POST | properties/534525683/keyEvents | Create a key event. Body: eventName, countingMethod. Used to promote generate_lead. |
| DELETE | properties/534525683/keyEvents/{id} | Remove a key event. Used to delete cta_click. Note: purchase returns INVALID_ARGUMENT "The event cannot be deleted"; it is a protected default and stays inert. |
| GET | properties/534525683/dataRetentionSettings | Read eventDataRetention (found at TWO_MONTHS) and resetUserDataOnNewActivity. |
| PATCH | properties/534525683/dataRetentionSettings?updateMask=eventDataRetention | Raise retention. Body sets eventDataRetention to FOURTEEN_MONTHS. Not retroactive; applies in about 24 hours. |
| GET | properties/534525683/dataStreams/14548640408/enhancedMeasurementSettings | (v1alpha) Read the seven Enhanced Measurement toggles. |
| PATCH | properties/534525683/dataStreams/14548640408/enhancedMeasurementSettings | (v1alpha) Toggle settings. Used to set pageChangesEnabled and formInteractionsEnabled to false. Pass an updateMask naming those fields. |
| GET | properties/534525683/dataStreams/14548640408/googleSignalsSettings | Confirm Google Signals state (found already DISABLED). |
| GET | properties/534525683/bigQueryLinks | List BigQuery links; used post-hoc to confirm dailyExportEnabled true and streaming off. |
| POST | properties/534525683/bigQueryLinks | Create a BigQuery link. Body: project, datasetLocation, dailyExportEnabled. Returned HTTP 403 for the service account; see D.5. |
| GET | properties/534525683/accessBindings | List who can access the property. Requires the analytics.manage.users.readonly scope. |
Representative bodies
Create a custom dimension (analytics.edit):
curl -s -X POST -H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
"https://analyticsadmin.googleapis.com/v1beta/properties/534525683/customDimensions" \
-d '{
"parameterName": "service_type",
"displayName": "Service Type",
"scope": "EVENT"
}'
error_message was registered the same way. It is safe to register because the application only ever sends bounded codes such as server_error and network_error, never raw user input; a dimension that could ingest free text would be a PII hazard, discussed in Chapter 10, Privacy and Consent by Design.
Create a key event (analytics.edit):
curl -s -X POST -H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
"https://analyticsadmin.googleapis.com/v1beta/properties/534525683/keyEvents" \
-d '{ "eventName": "generate_lead", "countingMethod": "ONCE_PER_EVENT" }'
Raise event data retention (analytics.edit):
curl -s -X PATCH -H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
"https://analyticsadmin.googleapis.com/v1beta/properties/534525683/dataRetentionSettings?updateMask=eventDataRetention" \
-d '{ "eventDataRetention": "FOURTEEN_MONTHS" }'
The updateMask query parameter is mandatory on every PATCH. GA4 updates only the fields you name in the mask and leaves the rest untouched, so an omitted mask returns an error rather than silently overwriting the whole resource. Standard aggregate reports retain data effectively indefinitely regardless of this setting, so raising the cap from TWO_MONTHS to FOURTEEN_MONTHS changes only Explorations and funnels that lean on event- or user-scoped custom dimensions, which is precisely the by-service and by-county seasonal analysis this business needs.
Toggle Enhanced Measurement (analytics.edit, v1alpha):
curl -s -X PATCH -H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
"https://analyticsadmin.googleapis.com/v1alpha/properties/534525683/dataStreams/14548640408/enhancedMeasurementSettings?updateMask=pageChangesEnabled,formInteractionsEnabled" \
-d '{ "pageChangesEnabled": false, "formInteractionsEnabled": false }'
pageChangesEnabled double-fired page_view on client-side navigation, and formInteractionsEnabled auto-fired GA4's own form_start and form_submit, which collided with the site's custom form events. Chapter 8, Event Taxonomy and Custom Dimensions, records the collision and the fix.
D.3 Data API reference
Base host: analyticsdata.googleapis.com, version v1beta. Read scope: analytics.readonly. Two methods drove the audit's traffic and event queries.
| Method | Path | Purpose |
|---|---|---|
| POST | properties/534525683:runReport | Historical aggregate reports over a date range. Source of the 30-day baseline: 2,328 events, 401 active users, 527 sessions, and the source/medium breakdown that exposed google/cpc traffic with no Google Ads link. |
| POST | properties/534525683:runRealtimeReport | Last-30-minute activity. Used to confirm the corrected generate_lead key event appeared in Realtime within about 20 seconds of a live production submission. |
Example runReport body, sessions by source and medium over the trailing 30 days:
curl -s -X POST -H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
"https://analyticsdata.googleapis.com/v1beta/properties/534525683:runReport" \
-d '{
"dateRanges": [{ "startDate": "30daysAgo", "endDate": "today" }],
"dimensions": [
{ "name": "sessionSource" },
{ "name": "sessionMedium" }
],
"metrics": [
{ "name": "sessions" },
{ "name": "activeUsers" },
{ "name": "eventCount" }
],
"orderBys": [{ "metric": { "metricName": "sessions" }, "desc": true }]
}'
That query returned (direct)/(none) 234, google/organic 192, google/cpc 46, yahoo/organic 16, bing/organic 12, and the long tail down to one yelp.com/referral session. The paid row with no Ads link is what motivated the offline-revenue loop in Chapter 11, The Offline-Revenue Loop.
D.4 Measurement Protocol reference
The Measurement Protocol is not on the Admin host. Server-side events POST to the collection endpoint; a parallel debug endpoint validates a payload without recording it. Authentication is not an OAuth token but two query parameters: measurement_id (G-WH410Z73V1) and an api_secret created in the stream's data-stream settings.
| Endpoint | Host and path | Behavior |
|---|---|---|
| Collect | https://www.google-analytics.com/mp/collect | Records the event. Returns HTTP 204 with an empty body on success. |
| Debug | https://www.google-analytics.com/debug/mp/collect | Validates the payload and returns validationMessages; does not record. |
The single most consequential defect in the entire audit lived in this one host string. The production code posted to https://www.google.com/mp/collect, which returns HTTP 404, so the server generate_lead never reached GA4 across the property's lifetime. The documented host https://www.google-analytics.com/mp/collect returns HTTP 204. Both were verified live with curl and the same payload. Chapter 6 dissects the failure; the lesson for this reference is blunt: the host is www.google-analytics.com, and a 204 with no body is success, not silence.
Validate before you trust:
curl -s -X POST \
"https://www.google-analytics.com/debug/mp/collect?measurement_id=G-WH410Z73V1&api_secret=${API_SECRET}" \
-H "Content-Type: application/json" \
-d '{
"client_id": "1234567890.1680000000",
"events": [{
"name": "generate_lead",
"params": {
"currency": "USD",
"value": 400,
"form_source": "contact",
"service_type": "termite",
"session_id": "1680000000",
"engagement_time_msec": 1
}
}]
}'
The corrected payload validated with validationMessages: []. Swap debug/mp/collect for mp/collect to record. The production send included ip_override set to the request IP so the conversion geolocates to the user rather than the datacenter, sent only when a real ga_client_id was present, and carried session_id parsed from the _ga_<container> cookie so the event attributes to the originating session rather than (direct)/(none). The parameter parsing for both the legacy GS1 dot-delimited and newer GS2 dollar-delimited cookie formats is documented in Chapter 6 and reproduced in Appendix B.