{"id":1328,"date":"2026-06-04T17:00:00","date_gmt":"2026-06-04T22:00:00","guid":{"rendered":"https:\/\/tolinku.com\/blog\/?p=1328"},"modified":"2026-03-07T03:49:08","modified_gmt":"2026-03-07T08:49:08","slug":"server-side-deferred-matching","status":"publish","type":"post","link":"https:\/\/tolinku.com\/blog\/server-side-deferred-matching\/","title":{"rendered":"Server-Side Deferred Matching: Architecture Guide"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Server-side deferred matching moves the matching logic from the client (the app) to a server. Instead of the app collecting device attributes and comparing them locally against stored click data, the app sends minimal information to a server, and the server performs the match. This architecture has significant advantages for privacy, accuracy, and maintainability.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This guide covers the architecture, data flow, and implementation of server-side deferred matching. For the matching methods themselves, see <a href=\"https:\/\/tolinku.com\/blog\/fingerprinting-vs-deterministic-matching\/\">fingerprinting vs. deterministic matching<\/a>. For accuracy expectations, see <a href=\"https:\/\/tolinku.com\/blog\/deferred-linking-accuracy\/\">deferred linking accuracy<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Why Server-Side?<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Client-Side Matching Problems<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">In a client-side approach, the app would need to:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Collect device attributes on first open.<\/li>\n<li>Send those attributes to a server that stores click data.<\/li>\n<li>Receive back the matching click&#39;s context.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">This is already partly server-side (the matching happens on a server), but some implementations embed matching logic in the SDK itself, or store click data in a way that exposes it to the client. The issues:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>SDK complexity.<\/strong> The SDK needs to implement fingerprinting, scoring, and matching logic. SDK updates are slow (users must update their app).<\/li>\n<li><strong>Privacy exposure.<\/strong> The client sees all the click data for potential matches, not just its own.<\/li>\n<li><strong>Inconsistency.<\/strong> Different SDK versions may use different matching algorithms, leading to inconsistent results.<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">Server-Side Advantages<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Moving all matching to the server provides:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Single source of truth.<\/strong> One matching algorithm, one scoring system, one set of rules. Updates take effect immediately without SDK updates.<\/li>\n<li><strong>Privacy.<\/strong> The app sends its attributes; the server returns a match (or not). The app never sees other users&#39; click data.<\/li>\n<li><strong>Better matching.<\/strong> The server has the full dataset of recent clicks and can apply sophisticated scoring, de-duplication, and conflict resolution.<\/li>\n<li><strong>Auditability.<\/strong> Server logs provide a complete audit trail of matches, useful for debugging and compliance.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Architecture<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Data Flow<\/h3>\n\n\n\n<pre><code>Click Phase:\n1. User clicks link \u2192 Landing page \/ redirect service\n2. Redirect service logs click data:\n   - Click ID\n   - IP address\n   - User agent\n   - Timestamp\n   - Deep link destination (path, parameters)\n   - Campaign \/ source metadata\n3. Redirect service sends user to app store\n\nInstall + First Open Phase:\n4. User installs and opens the app\n5. App SDK collects:\n   - IP address (from the network request itself)\n   - Device info (model, OS version)\n   - Install referrer (Android only)\n   - Clipboard token (if available)\n6. SDK sends POST request to matching endpoint\n7. Server matches against stored clicks\n8. Server returns match result (deep link context) or no match\n9. App routes user based on result\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">System Components<\/h3>\n\n\n\n<pre><code>\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510     \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510     \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502  Link Click  \u2502\u2500\u2500\u2500\u2500\u2192\u2502  Click Recorder  \u2502\u2500\u2500\u2500\u2500\u2192\u2502  Click Store \u2502\n\u2502  (Web\/Email) \u2502     \u2502  (API endpoint)  \u2502     \u2502  (Database)  \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518     \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518     \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n                                                      \u2502\n\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510     \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510             \u2502\n\u2502  App (SDK)   \u2502\u2500\u2500\u2500\u2500\u2192\u2502  Match Endpoint  \u2502\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n\u2502  First Open  \u2502     \u2502  (API endpoint)  \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518     \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n                            \u2502\n                     \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n                     \u2502 Match Result \u2502\n                     \u2502 (deep link)  \u2502\n                     \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Click Recording<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">What to Record<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">When a user clicks a deferred link, record:<\/p>\n\n\n\n<pre><code class=\"language-javascript\">\/\/ Click recording endpoint\napp.post(&#39;\/api\/v1\/clicks&#39;, (req, res) =&gt; {\n  const click = {\n    id: generateId(),\n    timestamp: Date.now(),\n    ip: req.headers[&#39;x-forwarded-for&#39;] || req.ip,\n    userAgent: req.headers[&#39;user-agent&#39;],\n    \/\/ Parsed attributes for matching\n    deviceModel: parseDeviceModel(req.headers[&#39;user-agent&#39;]),\n    osVersion: parseOsVersion(req.headers[&#39;user-agent&#39;]),\n    browser: parseBrowser(req.headers[&#39;user-agent&#39;]),\n    language: req.headers[&#39;accept-language&#39;]?.split(&#39;,&#39;)[0],\n    \/\/ Deep link context\n    destination: req.body.destination, \/\/ e.g., &quot;\/products\/123&quot;\n    campaign: req.body.campaign,\n    source: req.body.source,\n    \/\/ Matching tokens\n    clipboardToken: req.body.clipboardToken, \/\/ Token that was copied to clipboard\n    installReferrer: req.body.installReferrer, \/\/ For Play Store referrer\n  };\n\n  clickStore.save(click);\n\n  res.json({ clickId: click.id });\n});\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Storage Considerations<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Click data is ephemeral. It only needs to exist long enough for the matching window (typically 1-30 days, depending on method):<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>In-memory store (Redis):<\/strong> Fast reads, automatic expiration with TTL. Ideal for short windows (1-24 hours). Set TTL to your maximum matching window.<\/li>\n<li><strong>Database (PostgreSQL, MySQL):<\/strong> More durable, supports complex queries. Useful for longer windows (7-30 days). Run periodic cleanup to purge expired clicks.<\/li>\n<li><strong>Time-series store (ClickHouse, TimescaleDB):<\/strong> Optimized for time-windowed queries. Good if you also want analytics on click patterns.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Index on: <code>ip<\/code>, <code>timestamp<\/code>, and <code>clipboard_token<\/code> (if using clipboard matching).<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Match Endpoint<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Request<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The app SDK sends a match request on first open:<\/p>\n\n\n\n<pre><code class=\"language-javascript\">\/\/ Match endpoint\napp.post(&#39;\/api\/v1\/match&#39;, (req, res) =&gt; {\n  const deviceInfo = {\n    ip: req.headers[&#39;x-forwarded-for&#39;] || req.ip,\n    userAgent: req.headers[&#39;user-agent&#39;],\n    deviceModel: req.body.deviceModel,\n    osVersion: req.body.osVersion,\n    language: req.body.language,\n    installReferrer: req.body.installReferrer, \/\/ Android only\n    clipboardToken: req.body.clipboardToken,   \/\/ If paste was allowed\n    timestamp: Date.now(),\n  };\n\n  const match = findBestMatch(deviceInfo);\n\n  if (match) {\n    \/\/ Mark the click as matched (prevent re-matching)\n    clickStore.markMatched(match.clickId);\n\n    res.json({\n      matched: true,\n      destination: match.destination,\n      campaign: match.campaign,\n      source: match.source,\n      confidence: match.confidence,\n    });\n  } else {\n    res.json({ matched: false });\n  }\n});\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Matching Algorithm<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The matching logic tries deterministic methods first, then falls back to probabilistic:<\/p>\n\n\n\n<pre><code class=\"language-javascript\">function findBestMatch(deviceInfo) {\n  \/\/ Priority 1: Install Referrer (Android, deterministic)\n  if (deviceInfo.installReferrer) {\n    const match = clickStore.findByInstallReferrer(deviceInfo.installReferrer);\n    if (match) return { ...match, confidence: 1.0 };\n  }\n\n  \/\/ Priority 2: Clipboard token (deterministic)\n  if (deviceInfo.clipboardToken) {\n    const match = clickStore.findByClipboardToken(deviceInfo.clipboardToken);\n    if (match) return { ...match, confidence: 1.0 };\n  }\n\n  \/\/ Priority 3: Fingerprint matching (probabilistic)\n  const candidates = clickStore.findByIp(\n    deviceInfo.ip,\n    deviceInfo.timestamp - FINGERPRINT_WINDOW_MS\n  );\n\n  if (candidates.length === 0) return null;\n\n  \/\/ Score each candidate\n  const scored = candidates.map(click =&gt; ({\n    ...click,\n    confidence: calculateScore(click, deviceInfo),\n  }));\n\n  \/\/ Sort by confidence, pick the best\n  scored.sort((a, b) =&gt; b.confidence - a.confidence);\n\n  const best = scored[0];\n  if (best.confidence &gt;= MINIMUM_CONFIDENCE_THRESHOLD) {\n    return best;\n  }\n\n  return null;\n}\n\nfunction calculateScore(click, deviceInfo) {\n  let score = 0;\n\n  \/\/ IP match (strongest signal)\n  if (click.ip === deviceInfo.ip) score += 0.40;\n\n  \/\/ Device model match\n  if (click.deviceModel === deviceInfo.deviceModel) score += 0.15;\n\n  \/\/ OS version match\n  if (click.osVersion === deviceInfo.osVersion) score += 0.10;\n\n  \/\/ Language match\n  if (click.language === deviceInfo.language) score += 0.05;\n\n  \/\/ Recency bonus (more recent clicks score higher)\n  const ageHours = (deviceInfo.timestamp - click.timestamp) \/ (1000 * 60 * 60);\n  if (ageHours &lt; 1) score += 0.15;\n  else if (ageHours &lt; 6) score += 0.08;\n  else if (ageHours &lt; 24) score += 0.03;\n\n  return score;\n}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Conflict Resolution<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">When multiple clicks match with similar scores (e.g., same IP, same device model, within the time window), apply these rules:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Most recent click wins.<\/strong> If scores are equal, prefer the newer click.<\/li>\n<li><strong>Unmatched clicks only.<\/strong> Once a click is matched to an install, remove it from the candidate pool. This prevents one click from being attributed to multiple installs.<\/li>\n<li><strong>One match per install.<\/strong> An install can only match one click. If the match is ambiguous, either pick the best or return no match.<\/li>\n<\/ol>\n\n\n\n<h2 class=\"wp-block-heading\">Privacy Architecture<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Data Minimization<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The server should:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Store only the attributes needed for matching (IP, user agent, timestamp).<\/li>\n<li>Delete click data after the matching window expires.<\/li>\n<li>Not log or store the full match request (which contains the app user&#39;s device info) beyond the match operation.<\/li>\n<\/ul>\n\n\n\n<pre><code class=\"language-javascript\">\/\/ Cleanup job: run daily\nasync function cleanupExpiredClicks() {\n  const maxAge = 30 * 24 * 60 * 60 * 1000; \/\/ 30 days\n  const cutoff = Date.now() - maxAge;\n  await clickStore.deleteOlderThan(cutoff);\n}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Separation of Concerns<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The matching server should not have access to the app&#39;s user database. It knows click data and device attributes; it does not know who the user is. The match result (deep link destination and campaign metadata) is sent back to the app, which then handles user identity internally.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Encryption and Access Control<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Use HTTPS for all communication between the app SDK and the matching server.<\/li>\n<li>Authenticate match requests with an API key to prevent unauthorized access.<\/li>\n<li>Encrypt stored IP addresses at rest if your compliance requirements demand it.<\/li>\n<li>Log match operations for audit purposes, but redact IP addresses in logs.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">SDK Integration<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Android SDK Example<\/h3>\n\n\n\n<pre><code class=\"language-kotlin\">class DeferredLinkSDK(private val context: Context) {\n\n    private val apiUrl = &quot;https:\/\/api.yourservice.com\/v1\/match&quot;\n    private val apiKey = &quot;your_api_key&quot;\n\n    fun resolveDeferred(callback: (DeferredLinkResult?) -&gt; Unit) {\n        val deviceInfo = collectDeviceInfo()\n\n        \/\/ Send match request\n        val request = MatchRequest(\n            deviceModel = deviceInfo.model,\n            osVersion = deviceInfo.osVersion,\n            language = deviceInfo.language,\n            installReferrer = getInstallReferrer(),\n            clipboardToken = getClipboardToken(),\n        )\n\n        apiClient.post(apiUrl, request, apiKey) { response -&gt;\n            if (response.matched) {\n                callback(DeferredLinkResult(\n                    destination = response.destination,\n                    campaign = response.campaign,\n                    confidence = response.confidence,\n                ))\n            } else {\n                callback(null)\n            }\n        }\n    }\n}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">iOS SDK Example<\/h3>\n\n\n\n<pre><code class=\"language-swift\">class DeferredLinkSDK {\n\n    let apiUrl = &quot;https:\/\/api.yourservice.com\/v1\/match&quot;\n    let apiKey = &quot;your_api_key&quot;\n\n    func resolveDeferred(completion: @escaping (DeferredLinkResult?) -&gt; Void) {\n        let deviceInfo = collectDeviceInfo()\n\n        var request = MatchRequest()\n        request.deviceModel = deviceInfo.model\n        request.osVersion = deviceInfo.osVersion\n        request.language = deviceInfo.language\n        request.clipboardToken = getClipboardToken() \/\/ May trigger paste permission\n\n        apiClient.post(apiUrl, request: request, apiKey: apiKey) { response in\n            if response.matched {\n                completion(DeferredLinkResult(\n                    destination: response.destination,\n                    campaign: response.campaign,\n                    confidence: response.confidence\n                ))\n            } else {\n                completion(nil)\n            }\n        }\n    }\n}\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Tolinku&#39;s Server-Side Matching<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/tolinku.com\/features\/deep-linking\">Tolinku&#39;s platform<\/a> implements server-side deferred matching out of the box. When a user clicks a Tolinku link, the click data is recorded on Tolinku&#39;s servers. When the app opens and the SDK calls the match endpoint, Tolinku performs the matching server-side and returns the result. No fingerprint data is stored on the client.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Integrate with the <a href=\"https:\/\/tolinku.com\/docs\/developer\/api-reference\/\">Tolinku SDK<\/a> to get server-side matching with a single initialization call. For the broader deferred linking architecture, see <a href=\"https:\/\/tolinku.com\/blog\/deferred-deep-linking-how-it-works\/\">how deferred deep linking works<\/a>. For privacy architecture, see <a href=\"https:\/\/tolinku.com\/blog\/deferred-linking-privacy-considerations\/\">deferred linking privacy considerations<\/a>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Build server-side deferred deep link matching. Understand the architecture, data flow, privacy advantages, and implementation details of server-side attribution.<\/p>\n","protected":false},"author":2,"featured_media":1327,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"Server-Side Deferred Matching: Architecture Guide","rank_math_description":"Build server-side deferred deep link matching. Understand the architecture, data flow, and privacy advantages of server-side attribution.","rank_math_focus_keyword":"server-side deferred matching","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-server-side-deferred-matching.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-server-side-deferred-matching.png","footnotes":""},"categories":[11],"tags":[62,305,28,268,20,21,69,333],"class_list":["post-1328","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-deep-linking","tag-api","tag-architecture","tag-attribution","tag-backend","tag-deep-linking","tag-deferred-deep-linking","tag-mobile-development","tag-server-side"],"_links":{"self":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/1328","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=1328"}],"version-history":[{"count":3,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/1328\/revisions"}],"predecessor-version":[{"id":2587,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/1328\/revisions\/2587"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/media\/1327"}],"wp:attachment":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/media?parent=1328"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/categories?post=1328"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/tags?post=1328"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}