Install validation is the process of verifying that a reported app install is genuine. Without validation, your attribution data includes fake installs from device farms, emulators, SDK spoofing, and other fraud techniques. Accurate attribution depends on knowing which installs are real.
This guide covers install validation techniques. For click injection specifically, see click injection detection. For attribution fraud broadly, see attribution fraud: detection and prevention guide.
Why Install Validation Matters
In a typical mobile ad campaign, 10-30% of reported installs may be fraudulent (according to industry estimates). If you spend $50,000 on a campaign that delivers 10,000 installs, and 2,000 of those are fake, your effective CPI is $6.25 instead of $5.00. More importantly, your attribution data is corrupted: you are optimizing campaigns based on data that includes fake users.
Validation Techniques
App Store Receipt Validation
Both Apple and Google provide receipt validation APIs that confirm an install came from the official app store.
iOS: App Store Receipt
The App Store receipt is a cryptographically signed file that proves the app was downloaded from the App Store:
func validateAppStoreReceipt() -> Bool {
guard let receiptURL = Bundle.main.appStoreReceiptURL,
FileManager.default.fileExists(atPath: receiptURL.path) else {
return false // No receipt, not installed from App Store
}
guard let receiptData = try? Data(contentsOf: receiptURL) else {
return false
}
// Send to your server for validation
// Your server calls Apple's verifyReceipt endpoint
return sendToServerForValidation(receiptData)
}
Server-side validation:
async function validateAppleReceipt(receiptData: string): Promise<boolean> {
// Try production first
let response = await fetch('https://buy.itunes.apple.com/verifyReceipt', {
method: 'POST',
body: JSON.stringify({ 'receipt-data': receiptData }),
});
let result = await response.json();
// Status 21007 means sandbox receipt, retry with sandbox URL
if (result.status === 21007) {
response = await fetch('https://sandbox.itunes.apple.com/verifyReceipt', {
method: 'POST',
body: JSON.stringify({ 'receipt-data': receiptData }),
});
result = await response.json();
}
return result.status === 0; // 0 = valid
}
Android: Play Integrity API
The Play Integrity API (successor to SafetyNet) verifies that the app is genuine, the device is not rooted, and the install came from Google Play:
class IntegrityChecker(private val context: Context) {
suspend fun checkIntegrity(): IntegrityResult {
val integrityManager = IntegrityManagerFactory.create(context)
val nonce = generateNonce() // Server-generated nonce
val request = IntegrityTokenRequest.builder()
.setNonce(nonce)
.build()
val response = integrityManager.requestIntegrityToken(request).await()
val token = response.token()
// Send token to your server for verification
return verifyOnServer(token, nonce)
}
}
SDK Signature Verification
Verify that attribution SDK events are coming from your actual app, not from a spoofed SDK:
interface InstallEvent {
appId: string;
sdkVersion: string;
timestamp: number;
signature: string;
deviceInfo: DeviceInfo;
}
function verifySDKSignature(event: InstallEvent, secretKey: string): boolean {
const payload = `${event.appId}:${event.timestamp}:${event.deviceInfo.deviceId}`;
const expectedSignature = hmacSHA256(payload, secretKey);
return event.signature === expectedSignature;
}
Server-Side Install Verification
Verify installs on your server by checking multiple signals:
interface ValidationResult {
valid: boolean;
score: number; // 0-100 confidence score
flags: string[];
}
function validateInstall(event: InstallEvent): ValidationResult {
const flags: string[] = [];
let score = 100;
// Check 1: Device ID format
if (!isValidDeviceIdFormat(event.deviceInfo.deviceId)) {
flags.push('invalid_device_id');
score -= 40;
}
// Check 2: Timestamp sanity
const now = Date.now() / 1000;
if (Math.abs(now - event.timestamp) > 3600) {
flags.push('timestamp_drift');
score -= 20;
}
// Check 3: Known emulator signatures
if (isEmulatorSignature(event.deviceInfo)) {
flags.push('emulator_detected');
score -= 50;
}
// Check 4: App store receipt
if (!event.storeReceipt) {
flags.push('no_store_receipt');
score -= 30;
}
// Check 5: IP reputation
if (isDataCenterIP(event.deviceInfo.ip)) {
flags.push('datacenter_ip');
score -= 40;
}
// Check 6: Duplicate device ID
if (isDuplicateDevice(event.deviceInfo.deviceId)) {
flags.push('duplicate_device');
score -= 30;
}
return {
valid: score >= 50,
score: Math.max(0, score),
flags
};
}
Fraud Signals
Device-Level Signals
| Signal | Indicates |
|---|---|
| Rooted/jailbroken device | Possible emulator or tampered environment |
| Emulator detection | Device farms |
| Abnormal screen resolution | Non-standard device or emulator |
| Missing sensors (accelerometer, gyroscope) | Emulator |
| Time zone mismatch (device vs. IP) | VPN or location spoofing |
| Battery always at 100% | Emulator |
Behavioral Signals
| Signal | Indicates |
|---|---|
| No post-install activity | Bot install (never used) |
| Identical user agent across many installs | Device farm |
| Install at 3 AM local time | Automated install |
| Instant permission grants | Bot (real users take time to decide) |
| No scroll or gesture events | SDK spoofing (no real user) |
Network Signals
| Signal | Indicates |
|---|---|
| Data center IP | Server-based fraud |
| VPN/proxy IP | Location masking |
| Same IP for many installs | Device farm |
| IP country does not match targeting | Wrong geography |
Emulator Detection
Device farms use emulators to generate fake installs at scale. Common detection techniques:
fun isEmulator(): Boolean {
return (Build.FINGERPRINT.startsWith("generic")
|| Build.FINGERPRINT.startsWith("unknown")
|| Build.MODEL.contains("google_sdk")
|| Build.MODEL.contains("Emulator")
|| Build.MODEL.contains("Android SDK built for x86")
|| Build.BOARD == "QC_Reference_Phone"
|| Build.MANUFACTURER.contains("Genymotion")
|| Build.HOST.startsWith("Build")
|| Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")
|| "google_sdk" == Build.PRODUCT)
}
Note: Some legitimate users use emulators (developers testing their own apps). Consider the context before rejecting all emulator installs.
Handling Invalid Installs
When validation fails, you have three options:
- Reject immediately. Do not attribute the install. Send a rejection postback to the ad network.
- Flag for review. Mark the install as suspicious and continue attribution. Review flagged installs periodically.
- Quarantine. Hold the install in a pending state. If the user shows real engagement within 24-48 hours, accept it. If not, reject it.
The quarantine approach balances fraud prevention with false positive risk:
async function handleSuspiciousInstall(event: InstallEvent, validation: ValidationResult) {
if (validation.score < 20) {
// Very likely fraud, reject immediately
await rejectInstall(event, validation.flags);
} else if (validation.score < 50) {
// Suspicious, quarantine
await quarantineInstall(event, validation.flags);
// Check engagement after 48 hours
scheduleEngagementCheck(event.deviceInfo.deviceId, 48 * 3600);
} else {
// Probably legitimate, accept
await acceptInstall(event);
}
}
Tolinku for Install Tracking
Tolinku's analytics track deep link clicks with server-side validation, providing a clean source of truth for click-to-install attribution. Configure analytics in the Tolinku dashboard.
For click injection, see click injection detection. For mobile attribution, see mobile attribution: a developer's guide.
Get deep linking tips in your inbox
One email per week. No spam.