Sending product events from your backend instead of the browser

Server-side events survive ad blockers and can't be forged. The track call, the batch endpoint, and the identity discipline that makes the data joinable later.

Browser-side analytics loses a meaningful share of events to ad blockers, and everything it does send can be forged by anyone with developer tools. POST /v1/analytics/track on Infrai takes an event, a distinct_id and a properties object from your backend, where the event fires because something actually happened rather than because a script loaded.

The call is three fields. The discipline that makes the data useful a year later is all in distinct_id.

Track one event

curl -sS -X POST "https://api.infrai.cc/v1/analytics/track" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "event": "subscription_started",
    "distinct_id": "au_usr_lMmXGySJeGVM1xicqHGJaJbB",
    "properties": {"plan": "pro", "seats": 5, "annual": true, "amount_usd": 1188},
    "timestamp": "2026-09-21T03:55:00Z",
    "idempotency_key": "sub-start-9142"
  }'
{
  "ok": true,
  "data": { "accepted": true, "event_id": "evt_2fVc8nRqLmT4xBzY", "reason": null }
}

Four things in that request earn their place. distinct_id is the stable user identifier — the same value everywhere, forever. properties carries the dimensions you’ll want to segment by, and adding them later is impossible for events already sent. timestamp lets you send an event that happened five minutes ago without it being recorded as now, which matters for a worker draining a backlog. And idempotency_key means a retried request doesn’t double-count a subscription.

accepted and reason are worth reading rather than assuming: a rejected event tells you why.

Name events for a query you’ll write later

Event naming is the decision you can’t revise. subscription_started in past tense, snake case, one name per thing that happens — and no dynamic names.

import os
from typing import Any

import requests

API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})

# A closed set. Putting a value in the event NAME — "plan_pro_started" — creates a
# new event type per value, and every funnel query then has to know all of them.
# The value belongs in properties.
EVENTS = {
    "signup_completed", "subscription_started", "subscription_cancelled",
    "invoice_paid", "export_requested", "team_member_invited",
}


def track(event: str, distinct_id: str, properties: dict[str, Any] | None = None,
          *, idempotency_key: str | None = None, timestamp: str | None = None) -> dict:
    if event not in EVENTS:
        raise ValueError(f"unregistered event: {event}. Add it to EVENTS deliberately.")
    body: dict[str, Any] = {"event": event, "distinct_id": distinct_id,
                            "properties": properties or {}}
    if idempotency_key:
        body["idempotency_key"] = idempotency_key
    if timestamp:
        body["timestamp"] = timestamp

    resp = SESSION.post(f"{API}/v1/analytics/track", json=body, timeout=15)
    payload = resp.json()
    if not payload.get("ok"):
        # Analytics must never fail the business operation that produced it.
        return {"accepted": False, "reason": payload.get("error", {}).get("code")}
    return payload["data"]


def on_subscription_started(user_id: str, subscription_id: str, plan: str,
                            seats: int, annual: bool, amount_usd: int) -> None:
    track("subscription_started", user_id,
          {"plan": plan, "seats": seats, "annual": annual, "amount_usd": amount_usd},
          idempotency_key=f"sub-start-{subscription_id}")


if __name__ == "__main__":
    on_subscription_started(os.environ["USER_ID"], "9142", "pro", 5, True, 1188)

The registered-events set is worth the friction. Without it, a typo creates subscrition_started, which records happily and is invisible in every query that looks for the correct spelling.

Batch from a worker

A worker processing a backlog shouldn’t make one request per event:

curl -sS -X POST "https://api.infrai.cc/v1/analytics/batch" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "events": [
      {"event": "invoice_paid", "distinct_id": "au_usr_lMmXGySJeGVM1xicqHGJaJbB",
       "properties": {"amount_usd": 99}, "timestamp": "2026-09-21T03:40:00Z"},
      {"event": "export_requested", "distinct_id": "au_usr_lMmXGySJeGVM1xicqHGJaJbB",
       "properties": {"rows": 18422}, "timestamp": "2026-09-21T03:42:00Z"}
    ]
  }'
{
  "ok": true,
  "data": { "accepted": 2, "rejected": 0, "errors": [] }
}

accepted, rejected and errors — read all three. A batch that accepted 98 of 100 has two events with something wrong, and the errors array says which.

Each event carries its own timestamp, which is what makes batching honest: a backlog drained at 04:00 records events at the times they happened.

Never let analytics break the product

The track function above returns a failure instead of raising, and that’s deliberate. An analytics call that throws inside a checkout handler turns a telemetry problem into a lost sale.

Two patterns work. Fire and forget, accepting that a failed call loses one event. Or enqueue with POST /v1/queue/publish and let a consumer deliver events with retries, which costs a queue and never loses anything. For revenue events, the second is worth it — for a page view, it isn’t.

Server-side versus browser-side, honestly

PropertyBrowserBackend
Blocked by extensionsoftennever
Can be forged by a useryesno
Sees client-side interactionsyesno — it doesn’t know about a click
Sees what actually happenedno — only what the page thoughtyes
Needs consent managementyesstill yes, for the data you keep

Row three is the real limitation. A backend can’t see that someone opened a dropdown and closed it again, so pure server-side analytics loses interaction detail. The usual answer is both: server-side for anything that matters commercially, browser-side for interaction behaviour.

Consent applies either way. GET /v1/auth/consent/check/{user_id}/{category} on the same key gates the analytics category before you send, which is the check a server-side pipeline is most likely to skip precisely because nothing in the browser reminds it.

Limitations

There’s no automatic capture and no session stitching: you send what you decide to send, and a session concept is yours to model in properties. There’s no SDK either, so whatever wrapper you write is your integration surface.

PostHog and Mixpanel both ship autocapture, session replay and a UI for building funnels without writing a query, which for a product team that wants to explore rather than instrument is worth paying for. What you get here is that the events, the queue that delivers them reliably, the consent check that gates them and the query side are one credential and one GET /v1/account/usage — tracking bills per event at a rate live in GET /v1/discovery/analytics.track (verified 2026-09-21), low enough that the queue matters more than the rate, and drifting downward as vendor contracts improve.

References

Browse more analytics developer guides