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
| Endpoint | Purpose |
|---|---|
GET /v1/m/config/{pk}?plat=ios&bid=app.nordlicht&sdk=1.0.0 | App configuration (AppConfig), cached 5 minutes (Cache-Control: private, max-age=300) |
POST /v1/m/batch | Screens, events, identify hashes and deep links of one app session (AppBatch v1, ≤ 64 KiB, gzip allowed) |
POST /v1/m/install | First open of an install with the attribution sources the end user allowed; answers the accuracy tier |
POST /v1/m/lead | X3.lead(): an inquiry from the app (202) |
POST /v1/m/r | Recording packets (X3R1, application/octet-stream); answers the text ok or off |
POST /v1/m/erase | X3.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).
Consent
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.
| Request | Server rule |
|---|---|
/v1/m/batch | stored only when analytics is g (else 200 {"ok":true,"stored":false,"reason":"consent"}) |
/v1/m/install | attribution 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/r | the 2-char cs of the packet (analytics + recordings) must be gg; health projects additionally need cm = opt_in |
| uploads to ad platforms | only 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" }
}instis a random UUID of the install (app-private storage, excluded from backups); the server keeps only a keyed hash of it.sidis the session (30 minutes without activity end it),scrthe screen views (1–20) with their full current state – a later send of the same screen view raisesseq.- Times are device epoch milliseconds; the server corrects the clock with
serverNow − atand keeps offline batches up to 7 days.pathis the normalized screen path (PaywallViewController→/paywall). - Optional fields that are absent or false are omitted, never
null. idcarries only SHA-256 hashes:xidof the user id,em/emgof the e-mail (the Google variant without dots in Gmail local parts),lidof a lead id. Identify never creates a lead; the per-app setting „Signup ist Lead“ turnsaccount_createdplus an identity into one.purchaseis 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.
X3 links
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}): thesignedPayloadJWS 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. Answers204. - RevenueCat (
/v1/m/store/revenuecat/{token}): theAuthorizationheader 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 }.
| HTTP | code | SDK reaction |
|---|---|---|
| 400 | invalid | drop the request (a detail names the failing part) |
| 403 | key | unknown, revoked or not app-bound key: stop sending until the next config fetch succeeds |
| 403 | app_mismatch | platform or bundle id differ from the key's app (the registered bundle id is never echoed) |
| 403 | disabled | the app is disabled: stop sending until the config says enabled |
| 403 | lead_disabled | X3.lead() rejects with disabled |
| 413 | too_large | split the batch in halves, drop other requests |
| 429 | rate_limited | back off retryAfter seconds (default 60) |
| 503 | unavailable | retry with backoff |