Skip to content
Tolinku
Tolinku
Sign In Start Free
Deep Linking · · 6 min read

Server-Side Deferred Matching: Architecture Guide

By Tolinku Staff
|
Tolinku deep linking fundamentals dashboard screenshot for deep linking blog posts

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.

This guide covers the architecture, data flow, and implementation of server-side deferred matching. For the matching methods themselves, see fingerprinting vs. deterministic matching. For accuracy expectations, see deferred linking accuracy.

Why Server-Side?

Client-Side Matching Problems

In a client-side approach, the app would need to:

  1. Collect device attributes on first open.
  2. Send those attributes to a server that stores click data.
  3. Receive back the matching click's context.

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:

  • SDK complexity. The SDK needs to implement fingerprinting, scoring, and matching logic. SDK updates are slow (users must update their app).
  • Privacy exposure. The client sees all the click data for potential matches, not just its own.
  • Inconsistency. Different SDK versions may use different matching algorithms, leading to inconsistent results.

Server-Side Advantages

Moving all matching to the server provides:

  • Single source of truth. One matching algorithm, one scoring system, one set of rules. Updates take effect immediately without SDK updates.
  • Privacy. The app sends its attributes; the server returns a match (or not). The app never sees other users' click data.
  • Better matching. The server has the full dataset of recent clicks and can apply sophisticated scoring, de-duplication, and conflict resolution.
  • Auditability. Server logs provide a complete audit trail of matches, useful for debugging and compliance.

Architecture

Data Flow

Click Phase:
1. User clicks link → Landing page / redirect service
2. Redirect service logs click data:
   - Click ID
   - IP address
   - User agent
   - Timestamp
   - Deep link destination (path, parameters)
   - Campaign / source metadata
3. Redirect service sends user to app store

Install + First Open Phase:
4. User installs and opens the app
5. App SDK collects:
   - IP address (from the network request itself)
   - Device info (model, OS version)
   - Install referrer (Android only)
   - Clipboard token (if available)
6. SDK sends POST request to matching endpoint
7. Server matches against stored clicks
8. Server returns match result (deep link context) or no match
9. App routes user based on result

System Components

┌──────────────┐     ┌──────────────────┐     ┌──────────────┐
│  Link Click  │────→│  Click Recorder  │────→│  Click Store │
│  (Web/Email) │     │  (API endpoint)  │     │  (Database)  │
└──────────────┘     └──────────────────┘     └──────┬───────┘
                                                      │
┌──────────────┐     ┌──────────────────┐             │
│  App (SDK)   │────→│  Match Endpoint  │─────────────┘
│  First Open  │     │  (API endpoint)  │
└──────────────┘     └──────────────────┘
                            │
                     ┌──────┴──────┐
                     │ Match Result │
                     │ (deep link)  │
                     └─────────────┘

Click Recording

What to Record

When a user clicks a deferred link, record:

// Click recording endpoint
app.post('/api/v1/clicks', (req, res) => {
  const click = {
    id: generateId(),
    timestamp: Date.now(),
    ip: req.headers['x-forwarded-for'] || req.ip,
    userAgent: req.headers['user-agent'],
    // Parsed attributes for matching
    deviceModel: parseDeviceModel(req.headers['user-agent']),
    osVersion: parseOsVersion(req.headers['user-agent']),
    browser: parseBrowser(req.headers['user-agent']),
    language: req.headers['accept-language']?.split(',')[0],
    // Deep link context
    destination: req.body.destination, // e.g., "/products/123"
    campaign: req.body.campaign,
    source: req.body.source,
    // Matching tokens
    clipboardToken: req.body.clipboardToken, // Token that was copied to clipboard
    installReferrer: req.body.installReferrer, // For Play Store referrer
  };

  clickStore.save(click);

  res.json({ clickId: click.id });
});

Storage Considerations

Click data is ephemeral. It only needs to exist long enough for the matching window (typically 1-30 days, depending on method):

  • In-memory store (Redis): Fast reads, automatic expiration with TTL. Ideal for short windows (1-24 hours). Set TTL to your maximum matching window.
  • Database (PostgreSQL, MySQL): More durable, supports complex queries. Useful for longer windows (7-30 days). Run periodic cleanup to purge expired clicks.
  • Time-series store (ClickHouse, TimescaleDB): Optimized for time-windowed queries. Good if you also want analytics on click patterns.

Index on: ip, timestamp, and clipboard_token (if using clipboard matching).

Match Endpoint

Request

The app SDK sends a match request on first open:

// Match endpoint
app.post('/api/v1/match', (req, res) => {
  const deviceInfo = {
    ip: req.headers['x-forwarded-for'] || req.ip,
    userAgent: req.headers['user-agent'],
    deviceModel: req.body.deviceModel,
    osVersion: req.body.osVersion,
    language: req.body.language,
    installReferrer: req.body.installReferrer, // Android only
    clipboardToken: req.body.clipboardToken,   // If paste was allowed
    timestamp: Date.now(),
  };

  const match = findBestMatch(deviceInfo);

  if (match) {
    // Mark the click as matched (prevent re-matching)
    clickStore.markMatched(match.clickId);

    res.json({
      matched: true,
      destination: match.destination,
      campaign: match.campaign,
      source: match.source,
      confidence: match.confidence,
    });
  } else {
    res.json({ matched: false });
  }
});

Matching Algorithm

The matching logic tries deterministic methods first, then falls back to probabilistic:

function findBestMatch(deviceInfo) {
  // Priority 1: Install Referrer (Android, deterministic)
  if (deviceInfo.installReferrer) {
    const match = clickStore.findByInstallReferrer(deviceInfo.installReferrer);
    if (match) return { ...match, confidence: 1.0 };
  }

  // Priority 2: Clipboard token (deterministic)
  if (deviceInfo.clipboardToken) {
    const match = clickStore.findByClipboardToken(deviceInfo.clipboardToken);
    if (match) return { ...match, confidence: 1.0 };
  }

  // Priority 3: Fingerprint matching (probabilistic)
  const candidates = clickStore.findByIp(
    deviceInfo.ip,
    deviceInfo.timestamp - FINGERPRINT_WINDOW_MS
  );

  if (candidates.length === 0) return null;

  // Score each candidate
  const scored = candidates.map(click => ({
    ...click,
    confidence: calculateScore(click, deviceInfo),
  }));

  // Sort by confidence, pick the best
  scored.sort((a, b) => b.confidence - a.confidence);

  const best = scored[0];
  if (best.confidence >= MINIMUM_CONFIDENCE_THRESHOLD) {
    return best;
  }

  return null;
}

function calculateScore(click, deviceInfo) {
  let score = 0;

  // IP match (strongest signal)
  if (click.ip === deviceInfo.ip) score += 0.40;

  // Device model match
  if (click.deviceModel === deviceInfo.deviceModel) score += 0.15;

  // OS version match
  if (click.osVersion === deviceInfo.osVersion) score += 0.10;

  // Language match
  if (click.language === deviceInfo.language) score += 0.05;

  // Recency bonus (more recent clicks score higher)
  const ageHours = (deviceInfo.timestamp - click.timestamp) / (1000 * 60 * 60);
  if (ageHours < 1) score += 0.15;
  else if (ageHours < 6) score += 0.08;
  else if (ageHours < 24) score += 0.03;

  return score;
}

Conflict Resolution

When multiple clicks match with similar scores (e.g., same IP, same device model, within the time window), apply these rules:

  1. Most recent click wins. If scores are equal, prefer the newer click.
  2. Unmatched clicks only. Once a click is matched to an install, remove it from the candidate pool. This prevents one click from being attributed to multiple installs.
  3. One match per install. An install can only match one click. If the match is ambiguous, either pick the best or return no match.

Privacy Architecture

Data Minimization

The server should:

  • Store only the attributes needed for matching (IP, user agent, timestamp).
  • Delete click data after the matching window expires.
  • Not log or store the full match request (which contains the app user's device info) beyond the match operation.
// Cleanup job: run daily
async function cleanupExpiredClicks() {
  const maxAge = 30 * 24 * 60 * 60 * 1000; // 30 days
  const cutoff = Date.now() - maxAge;
  await clickStore.deleteOlderThan(cutoff);
}

Separation of Concerns

The matching server should not have access to the app'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.

Encryption and Access Control

  • Use HTTPS for all communication between the app SDK and the matching server.
  • Authenticate match requests with an API key to prevent unauthorized access.
  • Encrypt stored IP addresses at rest if your compliance requirements demand it.
  • Log match operations for audit purposes, but redact IP addresses in logs.

SDK Integration

Android SDK Example

class DeferredLinkSDK(private val context: Context) {

    private val apiUrl = "https://api.yourservice.com/v1/match"
    private val apiKey = "your_api_key"

    fun resolveDeferred(callback: (DeferredLinkResult?) -> Unit) {
        val deviceInfo = collectDeviceInfo()

        // Send match request
        val request = MatchRequest(
            deviceModel = deviceInfo.model,
            osVersion = deviceInfo.osVersion,
            language = deviceInfo.language,
            installReferrer = getInstallReferrer(),
            clipboardToken = getClipboardToken(),
        )

        apiClient.post(apiUrl, request, apiKey) { response ->
            if (response.matched) {
                callback(DeferredLinkResult(
                    destination = response.destination,
                    campaign = response.campaign,
                    confidence = response.confidence,
                ))
            } else {
                callback(null)
            }
        }
    }
}

iOS SDK Example

class DeferredLinkSDK {

    let apiUrl = "https://api.yourservice.com/v1/match"
    let apiKey = "your_api_key"

    func resolveDeferred(completion: @escaping (DeferredLinkResult?) -> Void) {
        let deviceInfo = collectDeviceInfo()

        var request = MatchRequest()
        request.deviceModel = deviceInfo.model
        request.osVersion = deviceInfo.osVersion
        request.language = deviceInfo.language
        request.clipboardToken = getClipboardToken() // May trigger paste permission

        apiClient.post(apiUrl, request: request, apiKey: apiKey) { response in
            if response.matched {
                completion(DeferredLinkResult(
                    destination: response.destination,
                    campaign: response.campaign,
                    confidence: response.confidence
                ))
            } else {
                completion(nil)
            }
        }
    }
}

Tolinku's Server-Side Matching

Tolinku's platform implements server-side deferred matching out of the box. When a user clicks a Tolinku link, the click data is recorded on Tolinku'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.

Integrate with the Tolinku SDK to get server-side matching with a single initialization call. For the broader deferred linking architecture, see how deferred deep linking works. For privacy architecture, see deferred linking privacy considerations.

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.