Skip to content
Tolinku
Tolinku
Sign In Start Free
Engineering · · 7 min read

Fraud Detection with Deep Link Webhooks

By Tolinku Staff
|
Tolinku cross platform dashboard screenshot for engineering blog posts

Referral programs pay real money. If someone discovers they can game your referral flow by generating fake installs or self-referring, they will. Click campaigns attract bots. Install attribution attracts fraud rings. The question isn't whether fraud will happen; it's whether you'll detect it before the monthly report or in real time.

Tolinku webhooks give you event-level data as it happens: every click, every install, every referral. That's exactly the data you need to build fraud detection rules. This guide covers the most common fraud patterns in deep link campaigns and how to detect them using webhook events. For the webhook setup, see the webhook setup guide.

Tolinku webhook configuration for event notifications The webhooks page with create form, webhook list, and delivery log.

Common Fraud Patterns

Click Fraud

Bots or click farms generate fake clicks to inflate campaign metrics, drain ad budgets, or manipulate analytics.

Signals in webhook data:

  • High click volume from a single IP address
  • Clicks with no subsequent installs (high click-to-install ratio)
  • Clicks from data center IP ranges (not residential IPs)
  • Identical user-agent strings across many clicks
  • Clicks arriving at perfectly regular intervals (non-human timing)

Install Fraud

Fake installs generated by device farms, emulators, or SDK spoofing to claim attribution credit or referral rewards.

Signals in webhook data:

  • Multiple installs from the same IP address
  • Installs that happen suspiciously fast after a click (under 5 seconds, suggesting automation)
  • Installs from known emulator fingerprints
  • High install volume with zero downstream engagement

Referral Fraud

Users creating multiple accounts to refer themselves, or organized groups coordinating referrals for reward harvesting.

Signals in webhook data:

  • A single referrer token generating an abnormal number of referrals in a short period
  • Referral chains (A refers B, B refers C, C refers A)
  • Referrals followed by immediate reward claims and account abandonment
  • Multiple referral.created events from the same IP

Building a Detection Pipeline

The architecture is straightforward: receive webhook events, check them against fraud rules, and flag or block suspicious activity.

Tolinku Webhook → Receiver → Fraud Rules Engine → Flag/Block
                                                 → Store for analysis

Receiver with Fraud Checking

import express from 'express';
import crypto from 'crypto';

const app = express();
app.use('/webhooks', express.raw({ type: 'application/json' }));

const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET!;

app.post('/webhooks/tolinku', async (req, res) => {
  const signature = req.headers['x-webhook-signature'] as string;
  const expected = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(req.body)
    .digest('hex');

  if (signature !== expected) {
    return res.status(401).send('Invalid signature');
  }

  res.status(200).send('OK');

  const event = JSON.parse(req.body.toString());
  const flags = await checkFraudRules(event);

  if (flags.length > 0) {
    await handleSuspiciousEvent(event, flags);
  }

  await storeEvent(event, flags);
});

Rule 1: IP Velocity

Flag events when a single IP generates too many events in a short window.

import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL!);

async function checkIPVelocity(event: any): Promise<string | null> {
  const ip = event.data.ip;
  if (!ip) return null;

  const key = `fraud:ip:${event.event}:${ip}`;
  const count = await redis.incr(key);

  if (count === 1) {
    await redis.expire(key, 3600); // 1-hour window
  }

  const thresholds: Record<string, number> = {
    'link.clicked': 100,        // 100 clicks/hour from one IP is suspicious
    'install.tracked': 5,       // 5 installs/hour from one IP is very suspicious
    'referral.created': 3,      // 3 referrals/hour from one IP is suspicious
  };

  const threshold = thresholds[event.event] || 50;
  if (count > threshold) {
    return `IP velocity: ${count} ${event.event} events from ${ip} in 1 hour (threshold: ${threshold})`;
  }

  return null;
}

Rule 2: Referrer Velocity

Flag when a single referrer token generates too many referrals too quickly.

async function checkReferrerVelocity(event: any): Promise<string | null> {
  if (event.event !== 'referral.created' && event.event !== 'referral.completed') {
    return null;
  }

  const token = event.data.referrer_token;
  if (!token) return null;

  const key = `fraud:referrer:${token}`;
  const count = await redis.incr(key);

  if (count === 1) {
    await redis.expire(key, 86400); // 24-hour window
  }

  // Normal users generate maybe 1-2 referrals per day. 10+ is suspicious.
  if (count > 10) {
    return `Referrer velocity: ${count} referrals from token ${token} in 24 hours`;
  }

  return null;
}

Rule 3: Click-to-Install Timing

Legitimate installs take time: the user clicks a link, visits the app store, downloads the app, and opens it. This process takes at minimum 30-60 seconds. An "install" within 5 seconds of a click suggests automation.

async function checkInstallTiming(event: any): Promise<string | null> {
  if (event.event !== 'install.tracked') return null;

  const token = event.data.token;
  const installTime = new Date(event.timestamp).getTime();

  // Look up the most recent click for this token
  const clickKey = `fraud:lastclick:${token}`;
  const lastClickTime = await redis.get(clickKey);

  if (lastClickTime) {
    const timeDiff = installTime - parseInt(lastClickTime);

    if (timeDiff < 5000) { // Under 5 seconds
      return `Suspiciously fast install: ${timeDiff}ms after click for token ${token}`;
    }
  }

  return null;
}

// Record click timestamps for timing analysis
async function recordClickTime(event: any) {
  if (event.event !== 'link.clicked') return;

  const token = event.data.token;
  const clickTime = new Date(event.timestamp).getTime();
  await redis.set(`fraud:lastclick:${token}`, clickTime.toString(), 'EX', 86400);
}

Rule 4: Campaign Anomalies

Compare real-time campaign metrics against historical baselines.

async function checkCampaignAnomaly(event: any): Promise<string | null> {
  if (event.event !== 'link.clicked') return null;

  const campaign = event.data.campaign;
  if (!campaign) return null;

  const key = `fraud:campaign:${campaign}:hourly`;
  const currentCount = await redis.incr(key);

  if (currentCount === 1) {
    await redis.expire(key, 3600);
  }

  // Get the baseline (stored separately, updated daily)
  const baselineKey = `fraud:campaign:${campaign}:baseline`;
  const baseline = parseInt(await redis.get(baselineKey) || '0');

  // If current hourly rate is 5x the average, flag it
  if (baseline > 0 && currentCount > baseline * 5) {
    return `Campaign anomaly: ${campaign} has ${currentCount} clicks this hour (baseline: ${baseline}/hour)`;
  }

  return null;
}

Rule 5: Geographic Anomalies

If your app only serves users in specific regions, clicks from unexpected countries are suspicious.

import { Reader } from '@maxmind/geoip2-node';

let geoReader: Reader;

async function checkGeoAnomaly(event: any): Promise<string | null> {
  const ip = event.data.ip;
  if (!ip) return null;

  try {
    const geo = geoReader.country(ip);
    const country = geo.country?.isoCode;

    // Data center IP ranges (no country) are suspicious
    if (!country) {
      return `No geo data for IP ${ip} (possible data center)`;
    }

    // Check against allowed countries (configure per campaign)
    const ALLOWED_COUNTRIES = new Set(['US', 'CA', 'GB', 'AU']);
    if (!ALLOWED_COUNTRIES.has(country)) {
      return `Unexpected country ${country} for IP ${ip}`;
    }
  } catch {
    // GeoIP lookup failed; could be a reserved/private IP
    return `GeoIP lookup failed for ${ip}`;
  }

  return null;
}

Combining Rules

Run all rules and aggregate the flags:

async function checkFraudRules(event: any): Promise<string[]> {
  // Record click time for timing analysis
  await recordClickTime(event);

  const checks = await Promise.allSettled([
    checkIPVelocity(event),
    checkReferrerVelocity(event),
    checkInstallTiming(event),
    checkCampaignAnomaly(event),
    checkGeoAnomaly(event),
  ]);

  const flags: string[] = [];
  for (const check of checks) {
    if (check.status === 'fulfilled' && check.value) {
      flags.push(check.value);
    }
  }

  return flags;
}

Responding to Fraud

When fraud is detected, you have several options:

1. Flag and Log

The lowest-risk response. Flag the event for manual review without blocking anything.

async function handleSuspiciousEvent(event: any, flags: string[]) {
  await db.query(
    `INSERT INTO fraud_flags (event_hash, event_type, flags, event_data, flagged_at)
     VALUES ($1, $2, $3, $4, NOW())`,
    [
      getEventHash(event),
      event.event,
      JSON.stringify(flags),
      JSON.stringify(event),
    ]
  );

  // Alert the team
  await sendSlackAlert(`Suspicious ${event.event} event flagged:\n${flags.join('\n')}`);
}

2. Delay Rewards

For referral fraud, don't block the referral; delay the reward. Hold rewards for 7-14 days and review flagged referrals before releasing payment.

3. Block the Source

For confirmed fraud (after manual review), block the IP, referral token, or campaign:

const BLOCKED_IPS = new Set<string>();
const BLOCKED_TOKENS = new Set<string>();

async function isBlocked(event: any): Promise<boolean> {
  if (BLOCKED_IPS.has(event.data.ip)) return true;
  if (BLOCKED_TOKENS.has(event.data.token)) return true;
  return false;
}

Load the blocklists from your database on startup and refresh periodically.

Dashboard Queries

Review fraud flags with these queries:

Most Flagged IPs

SELECT
  event_data->>'data'->>'ip' AS ip,
  COUNT(*) AS flag_count,
  array_agg(DISTINCT event_type) AS event_types
FROM fraud_flags
WHERE flagged_at > NOW() - INTERVAL '7 days'
GROUP BY ip
ORDER BY flag_count DESC
LIMIT 20;

Referral Fraud Candidates

SELECT
  event_data->'data'->>'referrer_token' AS referrer,
  COUNT(*) AS referral_count,
  COUNT(DISTINCT event_data->'data'->>'ip') AS unique_ips
FROM fraud_flags
WHERE event_type IN ('referral.created', 'referral.completed')
  AND flagged_at > NOW() - INTERVAL '30 days'
GROUP BY referrer
HAVING COUNT(*) > 5
ORDER BY referral_count DESC;

Click Fraud by Campaign

SELECT
  event_data->'data'->>'campaign' AS campaign,
  COUNT(*) AS flagged_clicks,
  COUNT(DISTINCT event_data->'data'->>'ip') AS unique_ips
FROM fraud_flags
WHERE event_type = 'link.clicked'
  AND flagged_at > NOW() - INTERVAL '7 days'
GROUP BY campaign
ORDER BY flagged_clicks DESC;

False Positives

Every fraud detection system generates false positives. A legitimate office building might have 50 employees clicking the same link from the same IP. A successful referrer might genuinely generate 15 referrals in a day.

Mitigate false positives by:

  • Starting with alerts, not blocks. Flag first, block later. Review flagged events for a few weeks before automating responses.
  • Using multiple signals. A single flag (high IP velocity) is weak. Multiple flags (high IP velocity + data center IP + no installs) are strong.
  • Tuning thresholds. Start with generous thresholds and tighten them as you understand your traffic patterns.
  • Whitelisting known IPs. Corporate VPNs and office buildings generate legitimate high-velocity traffic.

For webhook security (signature verification, secret rotation), see the webhook security guide. For monitoring delivery health, see the delivery monitoring guide.

Get deep linking tips in your inbox

One email per week. No spam.

Ready to add deep linking to your app?

Set up Universal Links, App Links, deferred deep linking, and analytics in minutes. Free to start.