A thin, read-only API that fetches normalized month-on-month Google Analytics 4 data for AI commentary workflows.
What it does
A disciplined surface area: deterministic data, no model output, no surprises.
Only preconfigured GA4 properties can be queried. Callers pass a client_key slug, not raw property IDs.
Computes current vs previous month for 7 key metrics with absolute and percentage change.
Optional per-channel metrics (Organic Search, Direct, Social, etc.) with the same comparison structure.
Flags like largest growth/decline channel, conversion efficiency changes, and traffic-vs-quality trade-offs.
Monthly data is cached for 6 hours to minimise GA4 API calls.
Every API call is logged with client key, status, duration, and error details.
AI Commentary Skill
A Codex / Cursor skill that calls this gateway and writes polished, client-ready month-on-month performance commentary. Non-technical users can use the interactive wizard; power users can pass parameters inline.
Type @@ga4-seo-commentary in your editor
Inline params, wizard, or screenshots
Live GA4 metrics via this API
Polished month-on-month narrative
Fastest path — provide client and month directly and skip the wizard.
@ga4-seo-commentary
client_key: elixirr
month: 2026-04
Just invoke the skill and answer the prompts. The wizard fetches the live client list from the properties endpoint and offers the last 3 months to pick from.
@ga4-seo-commentary
No API access? Attach GA4 screenshots or paste the numbers. The skill handles both.
@ga4-seo-commentary
# Then attach your GA4 screenshots or paste the metrics table
Sessions movement and top channel driver
Engagement rate trends by channel
Conversion rate and key event changes
Standout acquisition channels
Wins, concerns, and next steps
Download the skill zip and extract it into your Codex skills folder.
# Download from the gateway
curl -O https://poc-ga4-mcp-gateway.j.layershift.co.uk/downloads/ga4-seo-commentary-skill.zip
# Extract into your skills directory
unzip ga4-seo-commentary-skill.zip -d ~/.codex/skills/
Check the skill directory exists with the right structure.
ls ~/.codex/skills/ga4-seo-commentary/
# SKILL.md agents/ assets/ references/
Download the skill zip and extract it into your Codex skills folder.
# Download from the gateway (PowerShell)
Invoke-WebRequest -Uri "https://poc-ga4-mcp-gateway.j.layershift.co.uk/downloads/ga4-seo-commentary-skill.zip" -OutFile "ga4-seo-commentary-skill.zip"
# Extract into your skills directory
Expand-Archive -Path "ga4-seo-commentary-skill.zip" -DestinationPath "$env:USERPROFILE\.codex\skills\"
Check the skill directory exists with the right structure.
dir "$env:USERPROFILE\.codex\skills\ga4-seo-commentary\"
# SKILL.md agents/ assets/ references/
~/.codex/skills/API Key Management
Database-backed API keys replace the single shared secret. Keys are hashed at rest, support optional scoping to specific clients, and can be managed via CLI or the admin API.
Keys are SHA-256 hashed. The plaintext is shown once at creation and never stored.
Admin keys can manage other keys. Standard keys can only access data endpoints.
Keys can be restricted to specific client_key slugs, or left global for full access.
Revoked keys are timestamped for audit. Optional expiry dates for time-boxed access.
# Create a new admin key
php artisan api-key:create --name="Ross - Admin" --role=admin
# Create a scoped standard key
php artisan api-key:create --name="Acme Bot" --scoped-client-keys=acme,globex
# List active keys
php artisan api-key:list
# Revoke a key
php artisan api-key:revoke 3
# Seed legacy env var into database
php artisan api-key:seed-from-env
/api/v1/api-keys
Create a key (admin only). Returns plaintext key once.
/api/v1/api-keys
List keys (admin only). Never returns hashes.
/api/v1/api-keys/{id}
Revoke a key (admin only). Cannot self-revoke.
The legacy GA4_GATEWAY_API_KEY env var still works as a fallback. Existing integrations are unaffected.
Property Management
A web-based admin panel for managing allowlisted GA4 properties. Login at /admin with credentials created via artisan.
# Create an admin user
php artisan admin:create-user
# Start the server
php artisan serve
# Visit the admin panel
open http://127.0.0.1:8000/admin/login
Register new GA4 properties with client_key, name, and property ID.
Update property details and toggle active/inactive status.
Verify GA4 API credentials work for a property before going live.
Soft-delete properties. They stop appearing in API responses but data is preserved.
API reference
All endpoints are read-only and authenticated with a Bearer token.
/api/v1/health
No auth required
Smoke test. Returns 200 if the service is up.
{
"status": "ok"
}
/api/v1/ga4/properties
Bearer token
Lists all active, allowlisted properties the caller can query.
{
"data": [
{ "client_key": "elixirr", "name": "Elixirr.com" },
{ "client_key": "acme", "name": "Acme Corp" }
]
}
/api/v1/ga4/monthly-commentary-data
Bearer token
The main endpoint. Returns month-on-month comparison data for a given property.
| Name | Type | Required | Description |
|---|---|---|---|
client_key |
string | Yes | The slug of the GA4 property to query |
month |
string | Yes | Target month in YYYY-MM format |
include_channels |
boolean | No | Include per-channel breakdown (default: false) |
{
"data": {
"property": {
"client_key": "elixirr",
"name": "Elixirr.com",
"ga4_property_id": "properties/123456789"
},
"period": {
"current_month": "2026-04",
"previous_month": "2026-03",
"current_month_range": { "start": "2026-04-01", "end": "2026-04-30" },
"previous_month_range": { "start": "2026-03-01", "end": "2026-03-31" }
},
"summary": {
"sessions": {
"current": 12450, "previous": 11200,
"absolute_change": 1250, "percent_change": 11.16
},
"total_users": { "current": 9800, "previous": 8900, "absolute_change": 900, "percent_change": 10.11 },
"new_users": { "current": 7200, "previous": 6500, "absolute_change": 700, "percent_change": 10.77 },
"engaged_sessions": { "current": 8100, "previous": 7300, "absolute_change": 800, "percent_change": 10.96 },
"engagement_rate": { "current": 65.06, "previous": 65.18, "absolute_change": -0.12, "percent_change": -0.18 },
"key_events": { "current": 340, "previous": 290, "absolute_change": 50, "percent_change": 17.24 },
"session_key_event_rate": { "current": 2.73, "previous": 2.59, "absolute_change": 0.14, "percent_change": 5.41 }
},
"channels": [
{
"channel": "Organic Search",
"metrics": {
"sessions": { "current": 6200, "previous": 5500, "absolute_change": 700, "percent_change": 12.73 }
}
}
],
"insights": {
"top_growth_channel": { "channel": "Organic Search", "metric": "sessions", "percent_change": 12.73 },
"top_decline_channel": { "channel": "Direct", "metric": "sessions", "percent_change": -3.45 },
"conversion_efficiency": "improving",
"traffic_quality_tradeoff": null
},
"notes": [
"Data cached at 2026-05-12T10:30:00Z. Next refresh in ~5h."
]
}
}
Metrics reference
Every metric ships in the same comparison shape — current, previous, absolute_change, percent_change.
| Key | GA4 API Name | Type |
|---|---|---|
sessions |
sessions |
integer |
total_users |
totalUsers |
integer |
new_users |
newUsers |
integer |
engaged_sessions |
engagedSessions |
integer |
engagement_rate |
engagementRate |
percentage (0–100) |
key_events |
keyEvents |
integer |
session_key_event_rate |
sessionKeyEventRate |
percentage (0–100) |
{
"current": 12450,
"previous": 11200,
"absolute_change": 1250,
"percent_change": 11.16
}
Note: percent_change is null when previous is zero (division by zero guard).
Error codes
All errors return the same shape so downstream prompts can branch cleanly.
| Code | Meaning | When |
|---|---|---|
401 |
Unauthorized | Missing or invalid Bearer token |
404 |
Not Found | Unknown or inactive client_key |
422 |
Validation Error | Invalid or missing query parameters |
502 |
Bad Gateway | Downstream GA4 API or auth failure |
{
"error": {
"code": 422,
"message": "The client_key field is required.",
"details": {
"client_key": ["The client_key field is required."]
}
}
}
Quick start
composer install
cp .env.example .env
php artisan key:generate
# Add your GA4 credentials to .env:
# GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json
# GA4_GATEWAY_API_KEY=your-secret-token
php artisan migrate
php artisan ga4:property:upsert \
--client-key=elixirr \
--name="Elixirr.com" \
--property-id=123456789
php artisan serve
curl -s http://127.0.0.1:8000/api/v1/health
# {"status":"ok"}
curl -s -H "Authorization: Bearer your-secret-token" \
"http://127.0.0.1:8000/api/v1/ga4/monthly-commentary-data?client_key=elixirr&month=2026-04" \
| jq .
Known limitations
Ten honest gotchas. None are blockers — but assume them, not around them.
No daily, weekly, or custom date ranges. The API always compares full calendar months.
If you query the current month mid-way through, you'll get data up to today — not a projection.
We use the Default Channel Group dimension. Custom channel definitions in your GA4 property are not reflected.
The metric set is fixed. If GA4 adds new metrics or you need custom ones, the API must be updated.
GA4 access uses a service account JSON key. There is no user-level OAuth flow.
One set of Google credentials serves all properties. Ensure the service account has Viewer access to every property you configure.
Data refreshes every 6 hours. There is no webhook or manual purge endpoint.
This API is strictly read-only. It cannot modify GA4 properties, goals, or events.
API keys are now database-backed with SHA-256 hashing, optional scoping, and revocation. The legacy GA4_GATEWAY_API_KEY env var still works as a fallback. See the API Key Management section.
The API does not enforce per-caller rate limits. Abuse will hit the GA4 API quota. Use responsibly.