play-integrity-service
FRAUD PREVENTION NestJS 10 Cloud Run Stateless HMAC

Gate fraudulent installs
with Play Integrity verdicts

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.

01

Problem

Attribution platform
High
reported installs
Play Store actuals
Lower
verified installs
Gap
fraud
SDK spoof · emulator · root

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.

02

Goal & Non-goals

GOAL

Gate Adjust SDK install events with Google Play Integrity verdicts, reducing fraud volume and aligning Adjust install count closer to Play Store reality.

Target: gap ≤ 15% after 30 days
NON-GOALS
  • Catch click injection (bots on real devices) — Play Integrity has limited visibility
  • Replace Adjust Fraud Prevention Suite — this layers on top
  • Multi-app (phase 1 is LBP only; architecture is generic)
03

Architecture

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.

Standard flow (recommended)

sequenceDiagram participant Game as Unity Game
(Android) participant BE as play-integrity-
service (NestJS) participant Google as Google Play
Integrity API Note over Game: warm-up ONCE, cache provider Game->>Google: ① prepareIntegrityToken() Google-->>Game: tokenProvider (cached) Note over Game: ② requestHash = base64url(SHA256(
packageName|installId|gaid)) Game->>Google: ③ tokenProvider.request(requestHash) Google-->>Game: integrityToken Game->>BE: ④ POST /api/verify/standard
(token, requestHash, installId, gaid) BE->>Google: ⑤ decodeIntegrityToken Google-->>BE: {appIntegrity,
deviceIntegrity,
accountDetails} BE->>BE: ⑥ recompute requestHash + match
→ apply policy (pass/fail) BE-->>Game: {verdict, reason,
decisionId} Game->>Game: ⑦ if verdict=pass:
init Adjust SDK
else: skip

Classic flow (legacy / high-value)

sequenceDiagram participant Game as Unity Game
(Android) participant BE as play-integrity-
service (NestJS) participant Google as Google Play
Integrity API Game->>BE: ① GET /api/nonce
(X-API-Key) BE-->>Game: {nonce, expiresAt} Game->>Game: ② IntegrityManager.
requestIntegrityToken(nonce) Game->>Google: ③ requestIntegrityToken Google-->>Game: integrityToken Game->>BE: ④ POST /api/verify
(token, nonce) BE->>Google: ⑤ decodeIntegrityToken Google-->>BE: {appIntegrity,
deviceIntegrity,
accountDetails} BE->>BE: ⑥ verify HMAC nonce + match
→ apply policy (pass/fail) BE-->>Game: {verdict, reason,
decisionId} Game->>Game: ⑦ if verdict=pass:
init Adjust SDK
else: skip
Base path
/api/*
All business + probe routes prefixed with /api. Only /docs (Swagger UI) stays at root.
Endpoints
  • POST /api/verify/standard — verdict (Standard, recommended)
  • POST /api/verify — verdict (Classic, nonce)
  • GET /api/nonce — issue HMAC nonce (Classic)
  • GET /api/healthz — liveness
  • GET /api/readyz — readiness
  • GET /docs — Swagger UI
REQUEST BODY POST /api/verify · /api/verify/standard verdict depends on network (per-network policy)
FieldExampleRequiredNotes
tokeneyJhbGci…yesPlay Integrity token from the device.
noncea…(112 hex)ClassicClassic flow only — from GET /api/nonce.
requestHashgm44-9n0…optionalStandard flow. If sent → server recomputes base64url(SHA256(packageName|installId|gaid)) + must match; omit → binding skipped.
installReferrerutm_source=google-play&utm_medium=organicsend RAWSource of truth for network. RAW Play/Meta referrer — server parses it (do NOT pre-map). ≤1024. Missing → STRICT.
networkoem-xiaomioptionalLog-only — server does NOT use it for the verdict (parses from RAW referrer instead).
campaignsummer_sale_2026optionalLog-only — never gates the verdict.
gaid · installId · adjustAdidoptionalIdentifiers; gaid + installId required only when requestHash is sent (they form the hash). adjustAdid is log-only.
adjustNetwork · adjustCampaign · adjustTrackerOrganic…optionalLegacy Adjust fields — log-only.
Response adds resolvedNetwork + bucket (RELAXED / STRICT / OEM) when per-network policy is ON (env PER_NETWORK_POLICY_ENABLED, default off → legacy uniform logic).
04

Authentication & Client Headers

REQUIRED X-API-Key header

Static secret in X-API-Key header. SHA-256 hashed both sides → crypto.timingSafeEqual (constant-time, resists timing attacks).

OPTIONAL · PASSIVE Client tracking headers Logged, never gate logic
Header Example Notes
X-Bundle-Idcom.iecgames.lbpShould match server PLAY_INTEGRITY_PACKAGE_NAME
X-Platformandroid / iosPhase 1: Android only
X-Version-Name1.4.2Semantic app version
X-Version-Code142Android versionCode / iOS build number
X-Os-VersionAndroid 14Free-form
X-Device-ModelPixel 8 ProBuild.MODEL on Android
X-Device-Localevi-VNBCP-47 locale tag
Cloud Logging query
jsonPayload.client.x-platform="android" AND jsonPayload.client.x-version-name="1.4.2"
05

Tech Stack

Component Choice Why
FrameworkNestJS 10 + TypeScriptTeam expertise, DI, Swagger auto-gen
DeploymentCloud Run (us-central1)SEA user base, ~150ms RTT savings vs us-central1
AuthPer-tenant SA JSON keyDecode authz từ Play Console linking, no cross-project IAM grant
CacheStateless HMAC (no Redis)YAGNI — saves $80/mo + 1 IO hop
Loggingpino + Cloud LoggingJSON structured logs, native GCP integration
Static landing@nestjs/serve-staticInternal docs at / (env toggle)
ADR-006
Why no Redis?

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.

06

Success Metrics

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-installinflatedreflects fraud removal