DocsGuides

Guides

Collect browser and mobile events safely

4 min readReviewed July 2026

Browser and mobile applications cannot keep a GraphJSON workspace API key secret. Code, network requests, application bundles, and device storage are available to the person running the client.

Send client-originated events to an endpoint you control. That endpoint validates and enriches the event, then adds the GraphJSON key from server-side configuration.

browser or mobile app
        │
        │ first-party event request
        ▼
your collection endpoint
  ├─ authenticate or rate-limit
  ├─ allowlist event names and properties
  ├─ remove forbidden data
  ├─ add trusted account/environment fields
  └─ attach the GraphJSON API key
        │
        ▼
GraphJSON logging API

Do not send GRAPHJSON_API_KEY to the browser, place it in a mobile bundle, or expose it through a public runtime configuration variable.

Build a first-party endpoint#

This Next.js App Router example accepts a small public event contract:

// app/api/analytics/route.ts
import { NextRequest, NextResponse } from "next/server";

const allowedEvents = new Set([
  "page_viewed",
  "signup_started",
  "pricing_viewed"
]);

export async function POST(request: NextRequest) {
  const input = await request.json();

  if (!allowedEvents.has(input.event)) {
    return NextResponse.json({ error: "unsupported event" }, { status: 400 });
  }

  const event = {
    event: input.event,
    anonymous_id:
      typeof input.anonymous_id === "string"
        ? input.anonymous_id.slice(0, 100)
        : undefined,
    path:
      typeof input.path === "string" ? input.path.slice(0, 300) : undefined,
    referrer_host:
      typeof input.referrer_host === "string"
        ? input.referrer_host.slice(0, 200)
        : undefined,
    app_version: process.env.APP_VERSION,
    environment: process.env.APP_ENVIRONMENT
  };

  const graphjson = await fetch("https://api.graphjson.com/api/log", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      api_key: process.env.GRAPHJSON_API_KEY,
      collection: "web_product_events",
      json: JSON.stringify(event),
      timestamp: Math.floor(Date.now() / 1000)
    }),
    signal: AbortSignal.timeout(5000)
  });

  if (!graphjson.ok) {
    // Record only redacted diagnostics in your operational logging.
    return NextResponse.json({ accepted: false }, { status: 503 });
  }

  return NextResponse.json({ accepted: true });
}

For authenticated events, derive user_id and account_id from the server session. Never accept an arbitrary account_id from the browser and label it trusted.

Harden the endpoint#

A public collection endpoint can be abused even though the GraphJSON key remains hidden. Add controls appropriate to the event:

  • check the request origin where useful
  • apply IP, device, session, or account rate limits
  • cap request body size
  • allowlist event names
  • allowlist and truncate properties
  • reject nested objects you do not need
  • strip credentials, message bodies, URLs with tokens, and raw headers
  • attach trusted environment and release values on the server
  • return a generic response that does not expose GraphJSON errors

For authenticated product actions, prefer logging after the server commits the action. A browser “checkout completed” click is weaker evidence than the server’s confirmed order.

Send an event from the browser#

Use a small wrapper:

type BrowserEvent = {
  event: "page_viewed" | "signup_started" | "pricing_viewed";
  path?: string;
  referrer_host?: string;
};

export async function track(event: BrowserEvent) {
  await fetch("/api/analytics", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      ...event,
      anonymous_id: getAnonymousId()
    }),
    keepalive: true
  });
}

Analytics should not block navigation or make a product action fail. Decide which events are best effort and which should be emitted by the server after a durable business action.

Page views and single-page applications#

In a client-routed application, a full document load does not happen for every navigation. Emit page_viewed after the router confirms a route change.

Use a controlled payload:

{
  "event": "page_viewed",
  "path": "/pricing",
  "referrer_host": "example.com",
  "anonymous_id": "anon_01J...",
  "session_id": "ses_01J..."
}

Avoid sending:

  • complete URLs containing query strings
  • search terms without privacy review
  • page titles containing customer data
  • raw referrer URLs with identifiers
  • DOM text or form values

Normalize paths so /projects/123 and /projects/456 can be analyzed as /projects/:id when the identifier is not the question.

Page exit and lifecycle#

Browsers can terminate ordinary asynchronous requests during navigation. Use visibilitychange and send to your own first-party endpoint:

document.addEventListener("visibilitychange", () => {
  if (document.visibilityState !== "hidden") return;

  const body = JSON.stringify({
    event: "session_ended",
    anonymous_id: getAnonymousId(),
    duration_seconds: getSessionDuration()
  });

  navigator.sendBeacon(
    "/api/analytics",
    new Blob([body], { type: "application/json" })
  );
});

sendBeacon means the browser queued the request; it does not prove your server or GraphJSON accepted it. Keep exit events best effort and derive critical facts elsewhere.

Anonymous and authenticated identity#

Create a random, opaque anonymous_id on the device. Do not derive it from email, advertising identifiers, or a fingerprint.

After sign-in, send both identifiers where the analysis needs pre-login history:

{
  "event": "signup_completed",
  "anonymous_id": "anon_01J...",
  "user_id": "usr_42",
  "account_id": "acct_7"
}

GraphJSON does not automatically merge identities. Your SQL or event design defines whether anonymous and authenticated histories should be connected. Read Users, accounts, and identity.

Sessions#

GraphJSON does not create a universal client session model automatically.

If sessions matter, define:

  • when a session begins
  • inactivity timeout
  • foreground/background behavior
  • cross-tab behavior
  • whether authenticated and anonymous activity share a session
  • clock source

Generate an opaque session_id and send it with relevant events. Do not silently change the timeout later; version the session definition or record the change.

Mobile and offline delivery#

Mobile clients should queue events locally when the network is unavailable:

  1. create a stable event_id
  2. record the occurrence timestamp
  3. write the event to a bounded local queue
  4. deliver to your first-party endpoint in batches
  5. acknowledge only after the endpoint accepts it
  6. retry with backoff
  7. expire low-value events deliberately

Include:

{
  "event": "feature_used",
  "event_id": "evt_01J...",
  "occurred_at": 1785081000,
  "anonymous_id": "anon_01J...",
  "user_id": "usr_42",
  "app_version": "4.8.0",
  "platform": "ios",
  "schema_version": 1
}

Use the original occurrence time as the GraphJSON request timestamp. Otherwise events collected offline appear to happen when connectivity returns.

Bound the queue by age and size. When discarding is allowed, count discarded events locally and emit a diagnostic after recovery.

Apply your organization’s consent policy before enqueueing or sending an event. Turning consent off should stop future collection and clear any local queue that is no longer permitted.

Centralize the decision:

if (!analyticsConsentGranted()) return;

Do not scatter consent checks across dozens of button handlers. A single collection layer is easier to test and audit.

Test the pipeline#

  • The GraphJSON key never appears in a client bundle or request.
  • Unsupported event names return 400.
  • Oversized and malformed bodies are rejected.
  • Authenticated IDs come from the server session.
  • Test traffic goes to a non-production collection.
  • Offline events preserve occurrence time.
  • Duplicate delivery keeps the same event_id.
  • Consent stops enqueueing and delivery.
  • URLs, form values, and headers cannot leak secrets.
  • Failure does not block the product action.

For website campaign, referrer, and session definitions, continue with Web analytics and acquisition attribution. For platform-specific delivery patterns, use Native mobile instrumentation. Then review Reliable event delivery and Monitor instrumentation health.

Need a hand?

Tell us what you’re building and we’ll point you in the right direction.

Contact support