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

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

If your app reports its own purchases, a modified build can send a fake one and a retry can send a real one twice. For in-app purchase analytics on Jitsu, purchases come from your backend after the App Store or Google Play validates them, and every other event follows a written tracking plan. 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.
  • Apps send through Segment's open-source mobile libraries pointed at Jitsu. Engines and backends use the HTTP API.
  • Purchases carry the store's transaction ID as order_id and store:order_id as messageId.
  • strip-and-trust drops purchases sent with the app's write key, and never_send fields, on every connection.
  • plan-guard sends events outside the plan to an events_unplanned table in the warehouse.
  • drop-email removes the email trait on Mixpanel, Amplitude, PostHog and Statsig.

Who this is for

Mobile and game engineers writing the instrumentation, and the data engineer who owns the warehouse it lands in. Event names follow Segment's mobile spec and Google Analytics 4's recommended events, so it works with any pipeline that accepts Segment's format.

The problem

Besides client-side purchases, tracking plans break in two places. 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.

The CSV fixes the names, and three functions in the pipeline enforce them.

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. Context is separate: for events sent with a browser write key, Jitsu takes context.ip from the request (server-to-server docs), and Segment's common spec lists context.device.id among the fields its mobile libraries collect.

Naming follows three rules.

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.

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, and the domain from the site's setup page or a custom domain (Business and Enterprise plans).

With lifecycle tracking on, Segment's libraries send Application Installed, Application Opened, Application Updated and Application Backgrounded, and Analytics-Swift also sends Application Foregrounded (Segment's mobile spec). Jitsu's iOS and Android examples turn it on.

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

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'
});

Game engines and other clients

Jitsu has no Unity, Unreal or Godot SDK. 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. Such events carry no IP address unless the payload includes context.ip (docs). Turn on Strict Mode for that stream.

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

Two functions enforce the plan. Both are TypeScript in a jitsu-cli project, tested with planned events, client-sent purchases and malformed input. They are 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 never_send field:

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;

It drops money and economy events that arrive with the app's write key, and removes never_send properties from every track event. With it on every connection, a purchase the app reports through Segment's libraries reaches no destination and is not billed, because the function does not call fetch.

plan-guard runs after it, on warehouse connections only:

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.

A table name means nothing to a non-SQL destination, so this function stays off the other connections. Quarantined events still reach the warehouse and count as active events. To check a connection's functions, POST an event to /api/funcs/{connectionId}, which returns their output in the response.

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;

The query

ClickHouse, against the quarantine table plan-guard writes. Jitsu deduplicates ClickHouse rows with ReplacingMergeTree, so FINAL gives exact counts:

-- 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 new row after a release names the event, the broken rule and the library version that sent it.

Destinations

Live destinations for these events: ClickHouse, BigQuery, Snowflake, Redshift, Postgres and MySQL; Mixpanel, Amplitude and PostHog; Braze; Statsig; and Amazon S3 or Google Cloud Storage as an archive. Each of these connections runs strip-and-trust, the warehouse connections also run plan-guard, and Mixpanel, Amplitude, PostHog and Statsig also run drop-email. The retention and revenue page queries these events in ClickHouse.

Example implementation (illustrative): a puzzle game with a game server

Illustrative example: not a Jitsu customer. A free-to-play puzzle game ships on iOS and Android with a small game server that owns the economy.

Its clients send lifecycle, tutorial and level events through Segment's libraries. Currency, achievements and validated purchases come from the server over the HTTP API. Every connection runs strip-and-trust, and the ClickHouse connection also runs plan-guard.

A hypothetical release: the Android build, on Analytics-Kotlin, renames level_end to Level Complete, and an iOS build stops sending success. With three renamed events and two incomplete ones in the last week, the quarantine query returns these rows. This is hypothetical output, from the test table the query was checked against:

eventplan_errorcontext_library_namecontext_library_versioneventslast_seen
Level Completenot in tracking plananalytics-kotlin1.16.032026-10-14 10:00:00.000000
level_endmissing successanalytics-swift1.5.022026-10-14 11:00:00.000000

Where Jitsu does not fit

  • Engine SDKs. There is no Unity, Unreal or Godot SDK, so a game client's HTTP code and offline queue are yours to write and test.
  • Tracking-plan registry. Jitsu's docs have no tracking-plan feature. The plan lives in your repository and functions enforce it, while Schema Freeze stops new warehouse columns and keeps unmatched fields in _unmapped_data.
  • Device context columns. Jitsu's docs do not list the columns that Segment's mobile context produces, such as app version, device model and OS. Check them in Live Events before using them in SQL; the plan sends device_model as a property for that reason.
  • Server libraries. Jitsu documents the HTTP API for backends, and @jitsu/js in Node.js. Segment's server-side libraries are not in Jitsu's docs.

Questions people ask

Which events does Segment's mobile library send automatically? With lifecycle tracking on, Segment's libraries send Application Installed, Application Opened, Application Updated and Application Backgrounded, and Analytics-Swift also sends Application Foregrounded. Jitsu's iOS and Android examples turn it on; in React Native, add trackAppLifecycleEvents: true to createClient, since it defaults to false.

Should purchase events come from the app or the backend? From the backend, after it validates the purchase with the store. The strip-and-trust function drops purchase, subscription and virtual currency events that arrive with a client write key, on every connection.

What should order_id be? The store's transaction ID: Apple's transactionId or the Google Play order ID. With messageId set to store:order_id, a retried call deduplicates to one warehouse row, because Jitsu deduplicates on message_id.

Does this tracking plan need Jitsu? No. The names follow Segment's mobile spec and Google Analytics 4's recommended game events, so any pipeline that accepts Segment's format takes them as they are.

Get started with Jitsu

Get started on Jitsu Cloud, where the Free plan includes 200k active events a month and a ClickHouse warehouse.

Download the tracking plan, no form. To talk through volume or self-hosting, book a demo. The overview covers the whole pipeline.

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