NestJS backend service that verifies Google Play Integrity tokens before mobile attribution SDKs fire install events. Closes the gap between attribution-reported installs and real Play Store installs.
Mobile attribution SDKs often over-count installs relative to Play Store actuals — especially on incentive-heavy ad networks. The gap signals fraud: SDK spoofing, emulators, rooted devices, click injection. Each fraudulent install inflates marketing-spend attribution and wastes budget.
Gate Adjust SDK install events with Google Play Integrity verdicts, reducing fraud volume and aligning Adjust install count closer to Play Store reality.
Two flows, same verdict + shared pipeline. Standard is the recommended default — warm up a cached token provider, bind each request with a requestHash, no nonce round-trip, replay handled by Google Play, and the load lands on the decode quota instead of the on-device 10K cap. Classic (server-signed HMAC nonce) remains for legacy clients / high-value low-frequency actions.
| Field | Example | Required | Notes |
|---|---|---|---|
| token | eyJhbGci… | yes | Play Integrity token from the device. |
| nonce | a…(112 hex) | Classic | Classic flow only — from GET /api/nonce. |
| requestHash | gm44-9n0… | optional | Standard flow. If sent → server recomputes base64url(SHA256(packageName|installId|gaid)) + must match; omit → binding skipped. |
| installReferrer | utm_source=google-play&utm_medium=organic | send RAW | Source of truth for network. RAW Play/Meta referrer — server parses it (do NOT pre-map). ≤1024. Missing → STRICT. |
| network | oem-xiaomi | optional | Log-only — server does NOT use it for the verdict (parses from RAW referrer instead). |
| campaign | summer_sale_2026 | optional | Log-only — never gates the verdict. |
| gaid · installId · adjustAdid | … | optional | Identifiers; gaid + installId required only when requestHash is sent (they form the hash). adjustAdid is log-only. |
| adjustNetwork · adjustCampaign · adjustTracker | Organic… | optional | Legacy Adjust fields — log-only. |
Static secret in X-API-Key header. SHA-256 hashed both sides → crypto.timingSafeEqual (constant-time, resists timing attacks).
| Header | Example | Notes |
|---|---|---|
| X-Bundle-Id | com.iecgames.lbp | Should match server PLAY_INTEGRITY_PACKAGE_NAME |
| X-Platform | android / ios | Phase 1: Android only |
| X-Version-Name | 1.4.2 | Semantic app version |
| X-Version-Code | 142 | Android versionCode / iOS build number |
| X-Os-Version | Android 14 | Free-form |
| X-Device-Model | Pixel 8 Pro | Build.MODEL on Android |
| X-Device-Locale | vi-VN | BCP-47 locale tag |
jsonPayload.client.x-platform="android" AND jsonPayload.client.x-version-name="1.4.2"
| Component | Choice | Why |
|---|---|---|
| Framework | NestJS 10 + TypeScript | Team expertise, DI, Swagger auto-gen |
| Deployment | Cloud Run (us-central1) | SEA user base, ~150ms RTT savings vs us-central1 |
| Auth | Per-tenant SA JSON key | Decode authz từ Play Console linking, no cross-project IAM grant |
| Cache | Stateless HMAC (no Redis) | YAGNI — saves $80/mo + 1 IO hop |
| Logging | pino + Cloud Logging | JSON structured logs, native GCP integration |
| Static landing | @nestjs/serve-static | Internal docs at / (env toggle) |
Nonce re-use within 5-min TTL window is acceptable for fraud-gating — attacker still needs a valid one-shot integrity token from Google. Saves Memorystore + VPC connector overhead.
| Metric | Baseline | Target (30d) |
|---|---|---|
| Adjust vs Play Console gap | ~70% | ≤ 15% |
| /verify P99 latency | — | < 800ms |
| Legit pass rate | — | ≥ 95% |
| Fail-open events (BE down) | — | < 0.5% |
| MTG cost-per-install | inflated | reflects fraud removal |