The analytics domain forwards events to Google Analytics 4 (GA4) via the Measurement Protocol, for Google Ads conversion tracking and in-app engagement reporting.
These fire automatically from existing endpoints — user_registered from /api/auth/register and the Google/Apple registration flows, trial_started from the subscription trial endpoints, first_payment from the Stripe webhook, and session_active from /api/auth/refresh. There is no separate endpoint to call for these — this doc covers only the client-facing ingest endpoint below.
POST /api/analytics/track accepts either a single event or a batch of up to 25 (GA4 Measurement Protocol's own hard limit per call) — see the endpoint doc below for the exact shape. As an alternative to a separate request, a batch can also be attached directly to a token refresh call, saving a round trip.
gclid / gaClientId / platform)The following optional fields, when present on POST /api/auth/register, /api/auth/login, and the Google/Apple auth endpoints, are used to attribute conversion events to a Google Ads click and a GA4 client:
| Field | Type | Description |
|---|---|---|
gclid |
string | Google Ads click ID. First-touch — stored once per user, never overwritten by a later request. |
gaClientId |
string | GA4 client_id (web, from the _ga cookie) or Firebase app_instance_id (iOS/Android). Last-touch — refreshed on every request that supplies it. |
platform |
"web" | "android" | "ios" |
Which GA4 Measurement Protocol credentials to use when sending server-initiated events for this user. |
All three are optional and additive to the existing request bodies documented in Authentication API — omitting them simply means that request doesn't update attribution.
/api/analytics/track
Forward a client-driven engagement event (or app_install) to GA4 — either a single event or a batch. Public endpoint — accepts both anonymous callers (needed for pre-login events like app_install) and authenticated ones (the request is attributed to req.user.id when a valid session is present).
Headers:
Content-Type: application/json Authorization: Bearer <access_token> # optional
Request Body — single event:
{
"eventName": "content_search",
"gaClientId": "GA1.2.123456789.1234567890",
"platform": "ios",
"gclid": "Cj0KCQiA...",
"params": {
"searchTerm": "grace"
}
}
Request Body — batch (up to 25 events):
{
"gaClientId": "GA1.2.123456789.1234567890",
"platform": "ios",
"gclid": "Cj0KCQiA...",
"events": [
{ "eventName": "scripture_view", "params": { "book": "John", "chapter": 3 } },
{ "eventName": "content_search", "params": { "searchTerm": "grace" } }
]
}
gaClientId/platform/gclid are shared across the whole batch — GA4 Measurement Protocol accepts only one client_id (+ optional user_id) per call, so a batch is inherently scoped to one client/device. Each batch item may also carry an optional timestampMicros (Unix epoch microseconds) to report when the event actually occurred, forwarded to GA4 as a per-event timestamp_micros override.
eventName allowlist: app_install, session_active, content_search, book_navigation, scripture_view, topical_index_view, outline_view. Any other value is rejected — event names are not forwarded to GA4 free-form. Applies to both the single-event eventName and every item's eventName in a batch.
Limits:
events array: 1–25 items (GA4's own hard limit per call) — larger batches are rejected with a 400, not silently chunkedparams string values: 100 characters or fewer (GA4 silently truncates/drops longer values — rejected here instead so the client can fix it)Response (202 Accepted):
{
"message": "Event accepted"
}
Error Responses:
// 400 - Validation error (bad eventName, missing gaClientId/platform, batch too large,
// param value too long, or neither eventName nor events provided)
{
"message": "Validation error",
"code": "VALIDATION_ERROR",
"errors": [ ... ]
}
// 429 - Rate limited
{
"error": "Too many analytics events",
"message": "Too many analytics events from this IP, please try again later.",
"code": "RATE_LIMIT_EXCEEDED"
}
Notes:
gaClientId and platform are required; gclid is optional; exactly one of eventName or a non-empty events array is requiredsession_active is listed in the allowlist for completeness but is not normally sent via this endpoint — it fires server-side from /api/auth/refresh insteadapp_install with engagement events costs one GA4 call, not two — app_install is just another allowlisted event namePOST /api/auth/refresh) — see that endpoint's docs for the shape