App ingest protocol

The X3 app SDKs (Swift X3Kit, Kotlin io.x3growth:sdk, the React Native/Expo and Flutter wrappers) speak this protocol for you. You need it to build your own client, to run the first-party proxy, or to understand what you see in a network log. The browser protocol is described in Ingest protocol.

Every app request carries the app-bound publishable key (pk_3x_…, one per registered app, created in Einstellungen › Apps or with apps.create). The key only works on /v1/m/*, and only for the app's platform and bundle id / package name; web keys are refused there and app keys are refused on /v1/e. There is no Origin check: native apps have no origin.

Endpoints

EndpointPurpose
GET /v1/m/config/{pk}?plat=ios&bid=app.nordlicht&sdk=1.0.0App configuration (AppConfig), cached 5 minutes (Cache-Control: private, max-age=300)
POST /v1/m/batchScreens, events, identify hashes and deep links of one app session (AppBatch v1, ≤ 64 KiB, gzip allowed)
POST /v1/m/installFirst open of an install with the attribution sources the end user allowed; answers the accuracy tier
POST /v1/m/leadX3.lead(): an inquiry from the app (202)
POST /v1/m/rRecording packets (X3R1, application/octet-stream); answers the text ok or off
POST /v1/m/eraseX3.eraseDeviceData(): erase this install's data, proven with the device secret (202)
GET /v1/go/{ref}X3 link click: 302 to the App Store, Google Play (with the click token in the Play referrer) or the fallback URL
POST /v1/m/postbacks/{kind}/{endpoint}AdAttributionKit (aak) / SKAdNetwork (skan) postback copies – only through the first-party proxy
POST /v1/m/store/apple/{token}App Store Server Notifications V2
POST /v1/m/store/google/{token}Google Play Real-time developer notifications (Pub/Sub push)
POST /v1/m/store/revenuecat/{token}RevenueCat webhooks

Requests may carry Content-Encoding: gzip; the API inflates with the same caps. The SDKs send User-Agent: X3Kit/<version> (iOS <os>) or X3Android/<version> (Android <os>), wrappers append RN/<version> or Flutter/<version>; the bundle id is never part of the user agent.

Through your domain. createX3ProxyHandler (@x3growth/sdk/next/proxy, 1.3.0 or later) forwards /v1/m/* (bodies as bytes, content-encoding passed on), <prefix>/go/<code> and /v1/go/<ref> (the 302 and its location reach the device unchanged) and, when you mount the same handler on app/.well-known/appattribution/[...path]/route.ts and app/.well-known/skadnetwork/[...path]/route.ts, Apple's postback copies to /v1/m/postbacks/aak/<endpoint> and /v1/m/postbacks/skan/<endpoint>. Apple posts to a path with a trailing slash: the route must not redirect it (skipTrailingSlashRedirect: true). Store notifications are never proxied; they go to the API directly.

Configuration

{
  "v": 1, "rev": 7,
  "app": { "id": "5b0c6a64-4a8e-4f5e-9d38-2d6b9d1a7c11", "plat": "ios", "bid": "app.nordlicht" },
  "enabled": true,
  "flush": { "intervalMs": 10000, "events": 20 },
  "session": { "timeoutMs": 1800000 },
  "events": { "blocked": ["debug_ping"] },
  "screens": { "exclude": ["*admin*"] },
  "identify": { "enabled": true },
  "leads": { "enabled": true },
  "recording": { "enabled": true, "sampleRate": 1, "excludeScreens": ["*login*", "*checkout*"], "maskText": true, "maskImages": true,
                 "unmaskAnchors": [], "maskAnchors": [], "screenshots": false, "maxFps": 2, "wifiOnly": false, "health": false },
  "skan": null,
  "links": { "hosts": ["nordlicht.app"], "pathPrefix": "/n3/go" },
  "attestation": "off"
}

rev grows with every relevant change of the project or the app; a batch answer with a higher rev makes the SDK fetch the configuration again. plat and bid of the query must match the key's app (else 403 app_mismatch).

The app developer chooses the consent mode (cm): always, opt_in or opt_out; it is a required option of X3.configure, there is no default. Every request carries the consent string cs with one character per purpose in this order: analytics, attribution, recordings, ad measurement – g granted, d denied, p pending, x not used.

RequestServer rule
/v1/m/batchstored only when analytics is g (else 200 {"ok":true,"stored":false,"reason":"consent"})
/v1/m/installattribution sources (Play referrer, Apple Ads token, deep link) only when attribution is g, else the install is stored without them (tier: "unknown"); the identity tier (a login that links the install to an earlier web visit) also needs attribution g in the install's latest batch
/v1/m/rthe 2-char cs of the packet (analytics + recordings) must be gg; health projects additionally need cm = opt_in
uploads to ad platformsonly sessions whose ad measurement is g; iOS without ATT authorized: Meta and TikTok get app events without user data, Google Ads and LinkedIn nothing

X3.lead() and the device erase are explicit actions of the end user and work in every state.

Batch

{
  "v": 1, "k": "pk_3x_NordlichtDemoKey000000014VFxVY",
  "app": { "plat": "ios", "bid": "app.nordlicht", "av": "2.3.1", "ab": "231", "osv": "18.1", "dc": "phone", "sw": 390, "sh": 844,
           "loc": "de-DE", "tz": "Europe/Berlin", "lib": "x3-ios@1.0.0", "att": "not_determined" },
  "inst": "00000000-0000-4000-8000-000000000001",
  "sid": "00000000-0000-4000-8000-000000000002",
  "n": 1, "at": 1793174404000, "cm": "opt_out", "cs": "ggxx",
  "dp": "3138bb9bc78df27c473ecfd1410f7bd45ebac1f59cf3ff9cfe4db77aab7aedd3",
  "scr": [{ "id": "00000000-0000-4000-8000-000000000003", "seq": 1, "t0": 1793174400100, "t1": 1793174404000, "nav": "load",
            "path": "/paywall", "eng": 3900, "taps": 0, "itaps": 0 }],
  "ev": [
    { "s": "00000000-0000-4000-8000-000000000003", "n": 0, "t": 1793174400100, "e": "app_install" },
    { "s": "00000000-0000-4000-8000-000000000003", "n": 1, "t": 1793174400100, "e": "app_open", "p": { "cold": true } },
    { "s": "00000000-0000-4000-8000-000000000003", "n": 3, "t": 1793174404000, "e": "purchase",
      "p": { "order_id": "2000000123456789", "value": 59.99, "currency": "EUR", "items": 1, "product": "pro_yearly", "store": "apple" } }
  ],
  "id": { "s": "00000000-0000-4000-8000-000000000003",
          "xid": "5994471abb01112afcc18159f6cc74b4f511b99806da59b3caf5a9c173cacfc5",
          "em": "e17f30b80d5340f9601fbb19ea74134dab28d1378496ecd3162004eb6d633cdd",
          "emg": "d9fd1dc85220f0a4395c6720b3592b7620f7bcf48550e5a49f625f33f259c657" }
}
  • inst is a random UUID of the install (app-private storage, excluded from backups); the server keeps only a keyed hash of it. sid is the session (30 minutes without activity end it), scr the screen views (1–20) with their full current state – a later send of the same screen view raises seq.
  • Times are device epoch milliseconds; the server corrects the clock with serverNow − at and keeps offline batches up to 7 days. path is the normalized screen path (PaywallViewController → /paywall).
  • Optional fields that are absent or false are omitted, never null.
  • id carries only SHA-256 hashes: xid of the user id, em / emg of the e-mail (the Google variant without dots in Gmail local parts), lid of a lead id. Identify never creates a lead; the per-app setting „Signup ist Lead“ turns account_created plus an identity into one.
  • purchase is a hint: the conversion waits until the App Store, Google Play, RevenueCat or your server (purchases.create) confirms the same order id.
  • link ({ "s", "t", "tok"?, "code"?, "attr"? }) records a Universal Link / App Link that opened the app.

Answer: 200 {"ok":true,"stored":true,"rev":7} or 200 {"ok":true,"stored":false,"rev":7,"reason":"consent"|"quota"|"paused"|"bot"}. The SDK treats every 200 as delivered.

Install

{
  "v": 1, "k": "pk_3x_NordlichtAndroidKey000011GRvqN",
  "app": { "plat": "android", "bid": "app.nordlicht", "av": "2.3.1", "ab": "231", "osv": "15", "dc": "phone", "sw": 390, "sh": 844, "lib": "x3-android@1.0.0" },
  "inst": "00000000-0000-4000-8000-000000000001", "at": 1793174400000, "cm": "opt_out", "cs": "ggxx",
  "first": 1793174400000,
  "play": { "ref": "utm_source=google-play&utm_medium=organic", "clk": 0, "ins": 1793174390, "ver": "2.3.1" }
}

Sent once per install after analytics is granted and attribution is decided. Sources: play (the raw Play Install Referrer: X3 click token x3c, UTMs, gclid, the Meta Install Referrer when the app's decryption key is set), asa ({ "token": "…" }, the Apple Ads AdServices token, resolved by X3 with Apple in the background) and link (the deep link that started the first open). Answer: 200 {"ok":true,"tier":"referrer"} with one of referrer, apple_ads, link, identity, organic, unknown, pending (Apple Ads still resolving).

Lead

{ "v": 1, "k": "pk_3x_NordlichtDemoKey000000014VFxVY", "app": { "plat": "ios", "bid": "app.nordlicht" },
  "inst": "00000000-0000-4000-8000-000000000001", "sid": "00000000-0000-4000-8000-000000000002",
  "form": "kontakt", "email": "anna.berg@nordlicht.app", "firstName": "Anna", "message": "Bitte ruft mich an",
  "consent": { "marketing": true }, "fillMs": 9000, "eventId": "lead-0001-kontakt" }

Contact data travels once over TLS and is never stored on the device; X3 stores it like a browser lead. inst/sid link the lead to the app session (only while analytics is granted). 3 leads per minute per project, IP and install; a double submit within 2 minutes (same eventId, or install + form + contact) records one lead. The per-app switch „Leads aus der App“ off answers 403 lead_disabled. Answer: 202 {"ok":true}.

Recording packets

POST /v1/m/r takes X3R1 packets exactly like /v1/r ("X3R1" + header length + header JSON + gzip of NDJSON rrweb events), with three additional header fields: plat (ios / android), ins (the install id) and cm (the consent mode). App recordings are wireframes of the screen (rectangles, text shapes, images as placeholders, inputs always masked) serialized to rrweb; screenshots of screens the app lists travel once per hash as a Custom event {tag:"x3", payload:{e:"img", sha, mime, data}} and are stored as replay assets, but only while the app allows screenshot mode (Settings › Apps › Recordings, AppConfig.recording.screenshots) and never in health projects; otherwise the server drops the image bytes and keeps the wireframe. Answers: ok, off, 403 (secret, duplicate, key, app_mismatch), 413, 422 page_excluded, 429.

Erase

POST /v1/m/erase with { "v": 1, "k", "app": { "plat", "bid" }, "inst", "secret" } (the 64-hex device secret; batches send dp = its SHA-256). When the proof matches, the install's sessions, recordings, journey keys and account tokens are deleted. Always 202 {"ok":true}; 10 per minute per IP. Erasing a user by user id or e-mail from your server: POST /v1/projects/{project}/app-users/erase.

GET /v1/go/{ref} – ref is a link code (only through the proxy key of your domain: https://nordlicht.app/n3/go/app) or the link's global token (https://api.x3growth.io/v1/go/Abc123defG). The click finds the visitor's web session (?s=<session id>, else the same browser's daily visitor hash within 30 minutes), creates a click token and redirects: Android to Google Play with referrer=utm_…&x3c=<token>, iPhone to the App Store (pt, ct), everything else to the fallback URL. Cache-Control: no-store, Referrer-Policy: no-referrer, never a cookie. ?x3_test=1 marks the click as test traffic.

Store notifications

The three store URLs of an app (Einstellungen › Apps › Käufe, or apps.get → storeUrls) contain a secret 32-character token; apps.storeToken rotates it.

  • App Store Server Notifications V2 (/v1/m/store/apple/{token}): the signedPayload JWS is verified against Apple Root CA - G3 (x5c chain, ES256); the bundle id must be the app's. Enter the URL for Production and Sandbox in App Store Connect.
  • Google Play RTDN (/v1/m/store/google/{token}): a Pub/Sub push subscription; with push authentication the OIDC token is verified (audience = the app's push audience, else this URL; the service account when set). Only a notification signed by the app's push service account counts as verified: without one it is stored, but never forwarded and never applied as a refund. With a Play service account in the app's secrets, X3 reads order id, price and the obfuscated account id from the Play Developer API. Answers 204.
  • RevenueCat (/v1/m/store/revenuecat/{token}): the Authorization header must equal the app's RevenueCat secret.

Every accepted notification is forwarded unchanged to the app's own URL (headers x-x3-forwarded: 1 and x-x3-signature: sha256=<HMAC-SHA256 of the body with the store token>), retried after 1 min, 5 min, 30 min, 2 h, 6 h and 24 h. Verification failures answer 400 / 401 without processing.

Apple postbacks

POST /v1/m/postbacks/{kind}/{endpoint} accepts SKAdNetwork postbacks (kind=skan; versions 3 and 4, signature over the version-specific fields with Apple's key) and AdAttributionKit postbacks (kind=aak; the jws-string), only with a valid proxy key of your domain and only for an App Store id registered in the proxy key's projects. Postbacks are aggregated per ad network and campaign, never linked to a person; failed signatures are stored (at most 100 per project and hour) but never counted, and a signed copy replaces an unsigned one with the same transaction. The device sends them to your domain when the app's Info.plist names it (NSAdvertisingAttributionReportEndpoint and the AdAttributionKit copy endpoint).

Errors

/v1/m/* answer compact JSON, never RFC 9457 problems: { "ok": false, "code": "…", "retryAfter"?: seconds }.

HTTPcodeSDK reaction
400invaliddrop the request (a detail names the failing part)
403keyunknown, revoked or not app-bound key: stop sending until the next config fetch succeeds
403app_mismatchplatform or bundle id differ from the key's app (the registered bundle id is never echoed)
403disabledthe app is disabled: stop sending until the config says enabled
403lead_disabledX3.lead() rejects with disabled
413too_largesplit the batch in halves, drop other requests
429rate_limitedback off retryAfter seconds (default 60)
503unavailableretry with backoff