⚡ ⚡ Learn how Investing.com uses Jitsu to move data faster

In-app purchase analytics: an event tracking plan for mobile apps and games

Names drift between releases (Level Complete in one build, level_end in the next), and a funnel splits in two. Personal data leaks into properties: an email on a sign-up, a receipt on a purchase, message text on a share.

Purchases belong in the backend, sent after the App Store or Google Play validates them, since the app's own report can't be trusted. Every other event follows a written plan. The CSV fixes names and PII handling. Three Jitsu functions keep purchases and personal data in line with it and catch off-plan events in the warehouse, and a fourth trims what Braze gets. Download the tracking plan CSV, no form.

TL;DR

  • The plan has 33 events: Segment's lifecycle events, onboarding and paywall events, Google Analytics 4's game events, and backend purchase and economy events.
  • Each purchase and renewal counts once in the warehouse, deduplicated on the store's order ID (in ClickHouse, query with FINAL).
  • Purchases the app reports on its own never reach a destination, and the personal-data property names the function lists are stripped from track events.
  • Events outside the plan land in an events_unplanned warehouse table, routed there by plan-guard.
  • Mixpanel, Amplitude, PostHog and Statsig never see the email trait (drop-email), and Braze gets only the events its campaigns read (braze-allowlist).

Events to track

EventSent fromRequired propertiesPII handling
Application Installed, Application Openedapp, automatic with lifecycle tracking onnoneok
identifyappuserId (internal account ID)drop: email (outside Braze and the warehouse)
consent_updatedappanalytics, ads_personalizationnever_send: date_of_birth
paywall_viewedapppaywall_id, placementok
Order Completedbackendorder_id, product_id, revenue, currency, store, environment, productsnever_send: receipt, purchase_token
subscription_renewedbackendorder_id, product_id, revenue, currency, store, environmentnever_send: receipt, purchase_token
level_endgame client or serverlevel_name, successok
earn_virtual_currencygame servervirtual_currency_name, valueok
share_sentappchannel, content_typenever_send: message_text, contacts
client_errorapperror_codenever_send: free-text messages

The CSV has all 33 events, and a last row for the context fields every client event carries. In the PII column, never_send names a field to keep out at the source, drop names a field a function removes on every connection except the ones in brackets, and ok means no personal data in the event's own properties.

Naming follows three rules, taken from Segment's mobile spec and Google Analytics 4's recommended events. Segment's names stay for the lifecycle events its libraries send automatically, and for Order Completed from Segment's ecommerce spec, with one entry per item in products. Game events take Google Analytics 4's names. Everything else is snake_case, the form Jitsu's columns take anyway.

To stop new warehouse columns altogether, Schema Freeze keeps fields that match no column in _unmapped_data.

How events get in

iOS and Android

Install Segment's Analytics-Swift or Analytics-Kotlin and point it at your Jitsu domain, as in Jitsu's iOS docs and Android docs:

var analytics: Analytics? = nil

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Override point for customization after application launch.
        let configuration = Configuration(
          writeKey: "BROWSER_WRITE_KEY",
          apiHost: "your-jitsu-domain.com",
          cdnHost: "your-jitsu-domain.com")
            .trackApplicationLifecycleEvents(true)
            .flushInterval(10)

        analytics = Analytics(configuration: configuration)
        return true
}
// Add required imports
import com.segment.analytics.kotlin.android.Analytics
import com.segment.analytics.kotlin.core.*

// Create an analytics client with the given application context and Segment write key.
// NOTE: in android, application context is required to pass as the second parameter.
Analytics("BROWSER_WRITE_KEY", applicationContext) {
  apiHost = "your-jitsu-domain.com"
  cdnHost = "your-jitsu-domain.com"
  // Automatically track Lifecycle events
  trackApplicationLifecycleEvents = true
  flushAt = 3
  flushInterval = 10
}

Use the site's browser write key. The host is the domain on the site's setup page, or a custom domain on the Business and Enterprise plans.

The snippets above turn on lifecycle tracking, so install, open, update and background events arrive with no tracking calls of your own (Segment's mobile spec).

React Native

From Jitsu's React Native docs:

import { createClient } from '@segment/analytics-react-native';

const segmentClient = createClient({
  proxy: 'https://your-jitsu-domain.com/v1/batch',
  cdnProxy: 'https://your-jitsu-domain.com/v1/projects',
  writeKey: 'BROWSER_WRITE_KEY'
});

Add trackAppLifecycleEvents: true to createClient, since it defaults to false (analytics-react-native). After the first build, confirm the events arrive in Live Events.

Unity, Unreal, Godot and other clients

A game client posts the JSON the mobile libraries send to the HTTP API: one event to /api/s/track, or a batch to /v1/batch in Segment's batch format. The client keeps a stable anonymousId per install, a unique messageId per event, and its own retries and offline queue.

curl --location 'https://your-jitsu-domain.com/v1/batch' \
--header 'Content-Type: application/json' \
--data-raw '{
  "writeKey": "BROWSER_WRITE_KEY",
  "batch": [
    {
      "type": "track",
      "event": "level_end",
      "anonymousId": "5f0c2a1e-8d3b-4c7a-9e21-3b6d0f4a7c11",
      "messageId": "9b2e7f40-1c5d-4e8a-b3f6-0a7d2c9e5b18",
      "timestamp": "2026-10-15T09:31:40.000Z",
      "properties": { "level_name": "2-3", "success": false, "duration_s": 41.5, "fail_reason": "out_of_moves" }
    }
  ],
  "context": { "app": { "version": "3.4.0" } }
}'

Purchases from the backend

The backend sends purchases after validating them with Apple's App Store Server API or Google Play's Developer API, with a server-to-server write key. Turn on Strict Mode for that stream, and it accepts only requests with a valid write key. Jitsu does not read the IP from a server's request, so include context.ip when you want it (server-to-server docs).

curl --location 'https://your-jitsu-domain.com/api/s/track' \
--header 'Content-Type: application/json' \
--header 'X-Write-Key: keyId:keySecret' \
--data-raw '{
  "type": "track",
  "event": "Order Completed",
  "userId": "u_8412",
  "messageId": "app_store:2000000123456789",
  "timestamp": "2026-10-15T09:30:12.000Z",
  "properties": {
    "order_id": "2000000123456789",
    "product_id": "gems_500",
    "revenue": 4.99,
    "currency": "USD",
    "store": "app_store",
    "environment": "production",
    "products": [{ "product_id": "gems_500", "price": 4.99, "quantity": 1 }]
  },
  "context": { "ip": "203.0.113.7" }
}'

Put the store's transaction ID in order_id: Apple's transactionId or the Google Play order ID. With messageId set to store:order_id, a store notification delivered twice lands as one warehouse row, because Jitsu deduplicates warehouse rows on message_id; on ClickHouse, query with FINAL. The timestamp is the store's purchase time, not the time your job ran.

Each renewal needs its own order_id. Apple issues a new transactionId per renewal, but the Google Play Billing Library's getOrderId() returns the first order's ID, so read renewal order IDs on the server. Mark App Store sandbox purchases and Google Play test purchases of one-time products (purchaseType 0) with environment: sandbox.

The functions

Three functions enforce the plan. Each is TypeScript in a jitsu-cli project, built and deployed with jitsu-cli; the function editor in the Jitsu UI takes JavaScript, which is what jitsu-cli build outputs.

strip-and-trust runs first on every connection, so no destination receives a client-sent purchase or a track event property named in its NEVER_SEND list. Free-text fields, such as error messages, stay out at the source:

import { JitsuFunction } from "@jitsu/protocols/functions";
import { AnalyticsServerEvent } from "@jitsu/protocols/analytics";

export const config = {
  slug: "strip-and-trust",
  name: "Server-only events and never_send fields",
  description: "Drops money and economy events sent with a client write key. Removes never_send properties.",
};

// Money and economy events are kept only when they arrive on the server-to-server endpoint.
const SERVER_ONLY = new Set([
  "Order Completed",
  "subscription_started",
  "subscription_renewed",
  "subscription_canceled",
  "earn_virtual_currency",
  "spend_virtual_currency",
]);

// Properties the plan marks never_send. Removed from every track event, on every connection.
const NEVER_SEND = [
  "email",
  "phone",
  "date_of_birth",
  "receipt",
  "purchase_token",
  "advertising_id",
  "message_text",
  "contacts",
];

const stripAndTrust: JitsuFunction<AnalyticsServerEvent> = async (event, { log, source }) => {
  if (event.type !== "track") {
    return event;
  }
  const name = typeof event.event === "string" ? event.event : "";
  if (SERVER_ONLY.has(name) && source?.type !== "s2s") {
    log.warn(`Dropped ${name} sent with a client write key, messageId ${event.messageId}`);
    return "drop";
  }
  const properties = { ...(event.properties ?? {}) };
  for (const key of NEVER_SEND) {
    delete properties[key];
  }
  return { ...event, properties };
};

export default stripAndTrust;

plan-guard runs after it, on warehouse connections only, since a table name means nothing to other destinations:

import { JitsuFunction } from "@jitsu/protocols/functions";
import { AnalyticsServerEvent } from "@jitsu/protocols/analytics";

export const config = {
  slug: "plan-guard",
  name: "Tracking plan guard",
  description: "Sends track events outside the plan, or missing a required property, to a quarantine table.",
};

// One entry per track event in mobile-apps-and-games-tracking-plan.csv: event name -> required properties.
const PLAN = new Map<string, string[]>([
  ["Application Installed", []],
  ["Application Opened", []],
  ["Application Backgrounded", []],
  ["Application Updated", []],
  ["Application Foregrounded", []],
  ["consent_updated", ["analytics", "ads_personalization"]],
  ["onboarding_step_completed", ["step_index", "step_name"]],
  ["onboarding_completed", []],
  ["paywall_viewed", ["paywall_id", "placement"]],
  ["purchase_started", ["product_id", "store"]],
  ["purchase_failed", ["product_id", "error_code"]],
  ["Order Completed", ["order_id", "product_id", "revenue", "currency", "store", "environment", "products"]],
  ["subscription_started", ["order_id", "product_id", "revenue", "currency", "store", "environment"]],
  ["subscription_renewed", ["order_id", "product_id", "revenue", "currency", "store", "environment"]],
  ["subscription_canceled", ["product_id", "store"]],
  ["push_opened", ["campaign_id"]],
  ["ad_impression", ["ad_format", "placement"]],
  ["rewarded_ad_completed", ["placement", "reward_type"]],
  ["share_sent", ["channel", "content_type"]],
  ["client_error", ["error_code"]],
  ["performance_sample", ["fps_p50", "fps_p10"]],
  ["heartbeat", []],
  ["tutorial_begin", []],
  ["tutorial_complete", []],
  ["level_start", ["level_name"]],
  ["level_end", ["level_name", "success"]],
  ["level_up", ["level"]],
  ["unlock_achievement", ["achievement_id"]],
  ["earn_virtual_currency", ["virtual_currency_name", "value"]],
  ["spend_virtual_currency", ["virtual_currency_name", "value", "item_name"]],
  ["match_end", ["match_id", "mode", "result"]],
]);

// A required property is missing when it is absent, null, an empty string or an empty array.
const isMissing = (value: unknown) =>
  value === undefined || value === null || value === "" || (Array.isArray(value) && value.length === 0);

const QUARANTINE_TABLE = "events_unplanned";

const planGuard: JitsuFunction<AnalyticsServerEvent> = async (event, { log }) => {
  if (event.type !== "track") {
    return event;
  }
  const name = typeof event.event === "string" ? event.event : "";
  const properties = event.properties ?? {};
  const required = PLAN.get(name);
  const missing = (required ?? []).filter(key => isMissing(properties[key]));
  if (!required || missing.length > 0) {
    const planError = required ? `missing ${missing.join(", ")}` : "not in tracking plan";
    log.warn(`Quarantined "${name}": ${planError}`);
    return { ...event, properties: { ...properties, plan_error: planError }, JITSU_TABLE_NAME: QUARANTINE_TABLE };
  }
  return event;
};

export default planGuard;

It sends an event outside the plan, or missing a required property, to the events_unplanned table with a plan_error column, through JITSU_TABLE_NAME. An empty products array counts as missing.

Off-plan events still reach Mixpanel and Amplitude, so the quarantine table is where a team spots them the morning after a release. Quarantined rows stay queryable, and once the next build sends the right name, new events land back in events.

drop-email runs on the Mixpanel, Amplitude, PostHog and Statsig connections. Braze uses the email to contact the player and the warehouse is your own, so neither runs it. The email trait can arrive at the top level of an identify call or in context.traits on any event, and the function removes it from both:

import { JitsuFunction } from "@jitsu/protocols/functions";
import { AnalyticsServerEvent } from "@jitsu/protocols/analytics";

export const config = {
  slug: "drop-email",
  name: "Drop email trait",
  description: "Removes the email trait from every event. For connections outside Braze and the warehouse.",
};

const withoutEmail = <T extends Record<string, any> | undefined>(traits: T): T => {
  if (!traits || !("email" in traits)) {
    return traits;
  }
  const copy: Record<string, any> = { ...traits };
  delete copy.email;
  return copy as T;
};

const dropEmail: JitsuFunction<AnalyticsServerEvent> = async event => {
  const next: AnalyticsServerEvent = { ...event };
  if (event.traits) {
    next.traits = withoutEmail(event.traits);
  }
  if (event.context?.traits) {
    next.context = { ...event.context, traits: withoutEmail(event.context.traits) };
  }
  return next;
};

export default dropEmail;

Catching a broken release

This ClickHouse query reads the quarantine table plan-guard writes:

-- What broke the tracking plan in the last 7 days, and which library version sent it.
SELECT
    event,
    plan_error,
    context_library_name,
    context_library_version,
    count() AS events,
    max(timestamp) AS last_seen
FROM events_unplanned FINAL
WHERE timestamp >= today() - 7
GROUP BY event, plan_error, context_library_name, context_library_version
ORDER BY events DESC
LIMIT 50;

A release where the Android build, on Analytics-Kotlin, renames level_end to Level Complete, and an iOS build stops sending success, shows up in the quarantine query as:

eventplan_errorcontext_library_namecontext_library_versioneventslast_seen
Level Completenot in tracking plananalytics-kotlin1.16.04,8122026-10-14 10:00
level_endmissing successanalytics-swift1.5.02,3772026-10-14 11:00

What each destination gets

Braze runs one more function on its connection, passing identify calls and the five events Braze campaigns read:

import { JitsuFunction } from "@jitsu/protocols/functions";
import { AnalyticsServerEvent } from "@jitsu/protocols/analytics";

export const config = {
  slug: "braze-allowlist",
  name: "Braze allowlist",
  description: "Passes identify calls, and the track events Braze campaigns use when they carry a userId.",
};

// The track events Braze campaigns and segments read. Everything else stays out of Braze.
const BRAZE_EVENTS = new Set([
  "onboarding_completed",
  "paywall_viewed",
  "Order Completed",
  "subscription_started",
  "subscription_canceled",
]);

const brazeAllowlist: JitsuFunction<AnalyticsServerEvent> = async event => {
  if (event.type === "identify") {
    return event;
  }
  // Track events reach Braze only from signed-in users, so every one carries a userId.
  if (event.type === "track" && event.userId && typeof event.event === "string" && BRAZE_EVENTS.has(event.event)) {
    return event;
  }
  return "drop";
};

export default brazeAllowlist;

An onboarding_completed sent before sign-in still lands in ClickHouse and Mixpanel, since only signed-in users' track events reach Braze.

These events feed the ClickHouse queries on the retention and revenue page.

Questions people ask

Does the plan cover ad revenue events? Yes. ad_impression and rewarded_ad_completed are in the CSV, sent from the app. ad_impression carries revenue_usd, the impression revenue your ad mediation SDK reports, when it reports one.

How do I test the functions before a release? POST an event to /api/funcs/ followed by the connection's ID. The response shows what each function on that connection returns.

Does an app moving off Segment have to rename its events? No. Jitsu accepts Segment's event format as it is, so existing names keep working. Add any custom names to PLAN in plan-guard, or they land in events_unplanned.

Get started with Jitsu

Copy the tracking plan CSV into your repository, then create a Jitsu Cloud workspace, and deploy strip-and-trust, plan-guard and drop-email, plus braze-allowlist if you send to Braze.

Book a demo to plan a migration off Segment around this plan, or see the other app and game jobs on the mobile apps and games overview.

Subscribe to the Jitsu newsletter
Occasional product updates when we ship something worth knowing about. No spam.