{"id":1180,"date":"2026-05-25T09:00:00","date_gmt":"2026-05-25T14:00:00","guid":{"rendered":"https:\/\/tolinku.com\/blog\/?p=1180"},"modified":"2026-03-07T03:35:01","modified_gmt":"2026-03-07T08:35:01","slug":"webhook-fraud-detection","status":"publish","type":"post","link":"https:\/\/tolinku.com\/blog\/webhook-fraud-detection\/","title":{"rendered":"Fraud Detection with Deep Link Webhooks"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">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&#39;t whether fraud will happen; it&#39;s whether you&#39;ll detect it before the monthly report or in real time.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/tolinku.com\/features\/webhooks\">Tolinku webhooks<\/a> give you event-level data as it happens: every click, every install, every referral. That&#39;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 <a href=\"https:\/\/tolinku.com\/blog\/webhook-setup-guide\/\">webhook setup guide<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><img decoding=\"async\" src=\"https:\/\/tolinku.com\/blog\/wp-content\/uploads\/2026\/03\/platform-webhooks.png\" alt=\"Tolinku webhook configuration for event notifications\">\n<em>The webhooks page with create form, webhook list, and delivery log.<\/em><\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Common Fraud Patterns<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Click Fraud<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Bots or click farms generate fake clicks to inflate campaign metrics, drain ad budgets, or manipulate analytics.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Signals in webhook data:<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>High click volume from a single IP address<\/li>\n<li>Clicks with no subsequent installs (high click-to-install ratio)<\/li>\n<li>Clicks from data center IP ranges (not residential IPs)<\/li>\n<li>Identical user-agent strings across many clicks<\/li>\n<li>Clicks arriving at perfectly regular intervals (non-human timing)<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">Install Fraud<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Fake installs generated by device farms, emulators, or SDK spoofing to claim attribution credit or referral rewards.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Signals in webhook data:<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Multiple installs from the same IP address<\/li>\n<li>Installs that happen suspiciously fast after a click (under 5 seconds, suggesting automation)<\/li>\n<li>Installs from known emulator fingerprints<\/li>\n<li>High install volume with zero downstream engagement<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">Referral Fraud<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Users creating multiple accounts to refer themselves, or organized groups coordinating referrals for reward harvesting.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Signals in webhook data:<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>A single referrer token generating an abnormal number of referrals in a short period<\/li>\n<li>Referral chains (A refers B, B refers C, C refers A)<\/li>\n<li>Referrals followed by immediate reward claims and account abandonment<\/li>\n<li>Multiple <code>referral.created<\/code> events from the same IP<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Building a Detection Pipeline<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The architecture is straightforward: receive webhook events, check them against fraud rules, and flag or block suspicious activity.<\/p>\n\n\n\n<pre><code>Tolinku Webhook \u2192 Receiver \u2192 Fraud Rules Engine \u2192 Flag\/Block\n                                                 \u2192 Store for analysis\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Receiver with Fraud Checking<\/h3>\n\n\n\n<pre><code class=\"language-typescript\">import express from &#39;express&#39;;\nimport crypto from &#39;crypto&#39;;\n\nconst app = express();\napp.use(&#39;\/webhooks&#39;, express.raw({ type: &#39;application\/json&#39; }));\n\nconst WEBHOOK_SECRET = process.env.WEBHOOK_SECRET!;\n\napp.post(&#39;\/webhooks\/tolinku&#39;, async (req, res) =&gt; {\n  const signature = req.headers[&#39;x-webhook-signature&#39;] as string;\n  const expected = crypto\n    .createHmac(&#39;sha256&#39;, WEBHOOK_SECRET)\n    .update(req.body)\n    .digest(&#39;hex&#39;);\n\n  if (signature !== expected) {\n    return res.status(401).send(&#39;Invalid signature&#39;);\n  }\n\n  res.status(200).send(&#39;OK&#39;);\n\n  const event = JSON.parse(req.body.toString());\n  const flags = await checkFraudRules(event);\n\n  if (flags.length &gt; 0) {\n    await handleSuspiciousEvent(event, flags);\n  }\n\n  await storeEvent(event, flags);\n});\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Rule 1: IP Velocity<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Flag events when a single IP generates too many events in a short window.<\/p>\n\n\n\n<pre><code class=\"language-typescript\">import Redis from &#39;ioredis&#39;;\n\nconst redis = new Redis(process.env.REDIS_URL!);\n\nasync function checkIPVelocity(event: any): Promise&lt;string | null&gt; {\n  const ip = event.data.ip;\n  if (!ip) return null;\n\n  const key = `fraud:ip:${event.event}:${ip}`;\n  const count = await redis.incr(key);\n\n  if (count === 1) {\n    await redis.expire(key, 3600); \/\/ 1-hour window\n  }\n\n  const thresholds: Record&lt;string, number&gt; = {\n    &#39;link.clicked&#39;: 100,        \/\/ 100 clicks\/hour from one IP is suspicious\n    &#39;install.tracked&#39;: 5,       \/\/ 5 installs\/hour from one IP is very suspicious\n    &#39;referral.created&#39;: 3,      \/\/ 3 referrals\/hour from one IP is suspicious\n  };\n\n  const threshold = thresholds[event.event] || 50;\n  if (count &gt; threshold) {\n    return `IP velocity: ${count} ${event.event} events from ${ip} in 1 hour (threshold: ${threshold})`;\n  }\n\n  return null;\n}\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Rule 2: Referrer Velocity<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Flag when a single referrer token generates too many referrals too quickly.<\/p>\n\n\n\n<pre><code class=\"language-typescript\">async function checkReferrerVelocity(event: any): Promise&lt;string | null&gt; {\n  if (event.event !== &#39;referral.created&#39; &amp;&amp; event.event !== &#39;referral.completed&#39;) {\n    return null;\n  }\n\n  const token = event.data.referrer_token;\n  if (!token) return null;\n\n  const key = `fraud:referrer:${token}`;\n  const count = await redis.incr(key);\n\n  if (count === 1) {\n    await redis.expire(key, 86400); \/\/ 24-hour window\n  }\n\n  \/\/ Normal users generate maybe 1-2 referrals per day. 10+ is suspicious.\n  if (count &gt; 10) {\n    return `Referrer velocity: ${count} referrals from token ${token} in 24 hours`;\n  }\n\n  return null;\n}\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Rule 3: Click-to-Install Timing<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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 &quot;install&quot; within 5 seconds of a click suggests automation.<\/p>\n\n\n\n<pre><code class=\"language-typescript\">async function checkInstallTiming(event: any): Promise&lt;string | null&gt; {\n  if (event.event !== &#39;install.tracked&#39;) return null;\n\n  const token = event.data.token;\n  const installTime = new Date(event.timestamp).getTime();\n\n  \/\/ Look up the most recent click for this token\n  const clickKey = `fraud:lastclick:${token}`;\n  const lastClickTime = await redis.get(clickKey);\n\n  if (lastClickTime) {\n    const timeDiff = installTime - parseInt(lastClickTime);\n\n    if (timeDiff &lt; 5000) { \/\/ Under 5 seconds\n      return `Suspiciously fast install: ${timeDiff}ms after click for token ${token}`;\n    }\n  }\n\n  return null;\n}\n\n\/\/ Record click timestamps for timing analysis\nasync function recordClickTime(event: any) {\n  if (event.event !== &#39;link.clicked&#39;) return;\n\n  const token = event.data.token;\n  const clickTime = new Date(event.timestamp).getTime();\n  await redis.set(`fraud:lastclick:${token}`, clickTime.toString(), &#39;EX&#39;, 86400);\n}\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Rule 4: Campaign Anomalies<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Compare real-time campaign metrics against historical baselines.<\/p>\n\n\n\n<pre><code class=\"language-typescript\">async function checkCampaignAnomaly(event: any): Promise&lt;string | null&gt; {\n  if (event.event !== &#39;link.clicked&#39;) return null;\n\n  const campaign = event.data.campaign;\n  if (!campaign) return null;\n\n  const key = `fraud:campaign:${campaign}:hourly`;\n  const currentCount = await redis.incr(key);\n\n  if (currentCount === 1) {\n    await redis.expire(key, 3600);\n  }\n\n  \/\/ Get the baseline (stored separately, updated daily)\n  const baselineKey = `fraud:campaign:${campaign}:baseline`;\n  const baseline = parseInt(await redis.get(baselineKey) || &#39;0&#39;);\n\n  \/\/ If current hourly rate is 5x the average, flag it\n  if (baseline &gt; 0 &amp;&amp; currentCount &gt; baseline * 5) {\n    return `Campaign anomaly: ${campaign} has ${currentCount} clicks this hour (baseline: ${baseline}\/hour)`;\n  }\n\n  return null;\n}\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Rule 5: Geographic Anomalies<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">If your app only serves users in specific regions, clicks from unexpected countries are suspicious.<\/p>\n\n\n\n<pre><code class=\"language-typescript\">import { Reader } from &#39;@maxmind\/geoip2-node&#39;;\n\nlet geoReader: Reader;\n\nasync function checkGeoAnomaly(event: any): Promise&lt;string | null&gt; {\n  const ip = event.data.ip;\n  if (!ip) return null;\n\n  try {\n    const geo = geoReader.country(ip);\n    const country = geo.country?.isoCode;\n\n    \/\/ Data center IP ranges (no country) are suspicious\n    if (!country) {\n      return `No geo data for IP ${ip} (possible data center)`;\n    }\n\n    \/\/ Check against allowed countries (configure per campaign)\n    const ALLOWED_COUNTRIES = new Set([&#39;US&#39;, &#39;CA&#39;, &#39;GB&#39;, &#39;AU&#39;]);\n    if (!ALLOWED_COUNTRIES.has(country)) {\n      return `Unexpected country ${country} for IP ${ip}`;\n    }\n  } catch {\n    \/\/ GeoIP lookup failed; could be a reserved\/private IP\n    return `GeoIP lookup failed for ${ip}`;\n  }\n\n  return null;\n}\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Combining Rules<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Run all rules and aggregate the flags:<\/p>\n\n\n\n<pre><code class=\"language-typescript\">async function checkFraudRules(event: any): Promise&lt;string[]&gt; {\n  \/\/ Record click time for timing analysis\n  await recordClickTime(event);\n\n  const checks = await Promise.allSettled([\n    checkIPVelocity(event),\n    checkReferrerVelocity(event),\n    checkInstallTiming(event),\n    checkCampaignAnomaly(event),\n    checkGeoAnomaly(event),\n  ]);\n\n  const flags: string[] = [];\n  for (const check of checks) {\n    if (check.status === &#39;fulfilled&#39; &amp;&amp; check.value) {\n      flags.push(check.value);\n    }\n  }\n\n  return flags;\n}\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Responding to Fraud<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">When fraud is detected, you have several options:<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">1. Flag and Log<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The lowest-risk response. Flag the event for manual review without blocking anything.<\/p>\n\n\n\n<pre><code class=\"language-typescript\">async function handleSuspiciousEvent(event: any, flags: string[]) {\n  await db.query(\n    `INSERT INTO fraud_flags (event_hash, event_type, flags, event_data, flagged_at)\n     VALUES ($1, $2, $3, $4, NOW())`,\n    [\n      getEventHash(event),\n      event.event,\n      JSON.stringify(flags),\n      JSON.stringify(event),\n    ]\n  );\n\n  \/\/ Alert the team\n  await sendSlackAlert(`Suspicious ${event.event} event flagged:\\n${flags.join(&#39;\\n&#39;)}`);\n}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">2. Delay Rewards<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">For referral fraud, don&#39;t block the referral; delay the reward. Hold rewards for 7-14 days and review flagged referrals before releasing payment.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">3. Block the Source<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">For confirmed fraud (after manual review), block the IP, referral token, or campaign:<\/p>\n\n\n\n<pre><code class=\"language-typescript\">const BLOCKED_IPS = new Set&lt;string&gt;();\nconst BLOCKED_TOKENS = new Set&lt;string&gt;();\n\nasync function isBlocked(event: any): Promise&lt;boolean&gt; {\n  if (BLOCKED_IPS.has(event.data.ip)) return true;\n  if (BLOCKED_TOKENS.has(event.data.token)) return true;\n  return false;\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Load the blocklists from your database on startup and refresh periodically.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Dashboard Queries<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Review fraud flags with these queries:<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Most Flagged IPs<\/h3>\n\n\n\n<pre><code class=\"language-sql\">SELECT\n  event_data-&gt;&gt;&#39;data&#39;-&gt;&gt;&#39;ip&#39; AS ip,\n  COUNT(*) AS flag_count,\n  array_agg(DISTINCT event_type) AS event_types\nFROM fraud_flags\nWHERE flagged_at &gt; NOW() - INTERVAL &#39;7 days&#39;\nGROUP BY ip\nORDER BY flag_count DESC\nLIMIT 20;\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Referral Fraud Candidates<\/h3>\n\n\n\n<pre><code class=\"language-sql\">SELECT\n  event_data-&gt;&#39;data&#39;-&gt;&gt;&#39;referrer_token&#39; AS referrer,\n  COUNT(*) AS referral_count,\n  COUNT(DISTINCT event_data-&gt;&#39;data&#39;-&gt;&gt;&#39;ip&#39;) AS unique_ips\nFROM fraud_flags\nWHERE event_type IN (&#39;referral.created&#39;, &#39;referral.completed&#39;)\n  AND flagged_at &gt; NOW() - INTERVAL &#39;30 days&#39;\nGROUP BY referrer\nHAVING COUNT(*) &gt; 5\nORDER BY referral_count DESC;\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Click Fraud by Campaign<\/h3>\n\n\n\n<pre><code class=\"language-sql\">SELECT\n  event_data-&gt;&#39;data&#39;-&gt;&gt;&#39;campaign&#39; AS campaign,\n  COUNT(*) AS flagged_clicks,\n  COUNT(DISTINCT event_data-&gt;&#39;data&#39;-&gt;&gt;&#39;ip&#39;) AS unique_ips\nFROM fraud_flags\nWHERE event_type = &#39;link.clicked&#39;\n  AND flagged_at &gt; NOW() - INTERVAL &#39;7 days&#39;\nGROUP BY campaign\nORDER BY flagged_clicks DESC;\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">False Positives<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Mitigate false positives by:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Starting with alerts, not blocks.<\/strong> Flag first, block later. Review flagged events for a few weeks before automating responses.<\/li>\n<li><strong>Using multiple signals.<\/strong> A single flag (high IP velocity) is weak. Multiple flags (high IP velocity + data center IP + no installs) are strong.<\/li>\n<li><strong>Tuning thresholds.<\/strong> Start with generous thresholds and tighten them as you understand your traffic patterns.<\/li>\n<li><strong>Whitelisting known IPs.<\/strong> Corporate VPNs and office buildings generate legitimate high-velocity traffic.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">For webhook security (signature verification, secret rotation), see the <a href=\"https:\/\/tolinku.com\/blog\/webhook-security-signing\/\">webhook security guide<\/a>. For monitoring delivery health, see the <a href=\"https:\/\/tolinku.com\/blog\/webhook-delivery-monitoring\/\">delivery monitoring guide<\/a>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Detect referral fraud, click fraud, and install fraud in real time using webhook event patterns. Build automated defenses for your deep link campaigns.<\/p>\n","protected":false},"author":2,"featured_media":1179,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"Fraud Detection with Deep Link Webhooks","rank_math_description":"Detect referral fraud, click fraud, and install fraud in real time using webhook events. Build automated defenses for your deep link campaigns.","rank_math_focus_keyword":"webhook fraud detection","rank_math_canonical_url":"","rank_math_facebook_title":"","rank_math_facebook_description":"","rank_math_facebook_image":"https:\/\/tolinku.com\/blog\/wp-content\/uploads\/2026\/03\/og-webhook-fraud-detection.png","rank_math_facebook_image_id":"","rank_math_twitter_title":"","rank_math_twitter_description":"","rank_math_twitter_image":"https:\/\/tolinku.com\/blog\/wp-content\/uploads\/2026\/03\/og-webhook-fraud-detection.png","footnotes":""},"categories":[15],"tags":[37,20,264,126,45,93,61],"class_list":["post-1180","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-engineering","tag-analytics","tag-deep-linking","tag-engineering","tag-fraud-detection","tag-referrals","tag-security","tag-webhooks"],"_links":{"self":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/1180","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/comments?post=1180"}],"version-history":[{"count":2,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/1180\/revisions"}],"predecessor-version":[{"id":2269,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/1180\/revisions\/2269"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/media\/1179"}],"wp:attachment":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/media?parent=1180"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/categories?post=1180"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/tags?post=1180"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}