Server Setup

Configure the Relay server to handle fingerprinting and deferred link matching

Server Setup

The Relay server handles API requests from your client app, stores fingerprints and deferred links, and performs matching logic to reconnect users across installations.

Creating a Server Instance

Use createRelayServer() to create a server instance with your storage and hook configurations:

import { createRelayServer } from "@korsolutions/relay";

export const relayServer = createRelayServer({
  fingerprint: {
    methods: {
      storeFingerprint: async (fingerprint, hash) => {
        // Store fingerprint in your database
      },
      getFingerprintByHash: async (hash) => {
        // Retrieve fingerprint from database
      },
    },
  },
  deferredLink: {
    methods: {
      storeDeferredLink: async (deferredLink) => {
        // Store deferred link in your database
      },
      getDeferredLinkByFingerprintHash: async (fingerprintHash) => {
        // Get link by fingerprint hash
      },
      deleteDeferredLink: async (id) => {
        // Delete link after matching
      },
    },
  },
  hooks: {
    onMatchFound: async (deferredLink) => {
      // React to successful matches
    },
  },
});

Configuration Options

Fingerprint Methods

interface FingerprintMethods {
  // Hash a fingerprint (optional - uses default SHA-256 if not provided)
  hashFingerprint?: (data: Fingerprint) => Promise<string>;

  // Parse fingerprint data (optional - uses Zod validation by default)
  parseFingerprint?: (data: any) => Fingerprint;

  // Store a fingerprint with its hash
  storeFingerprint: (data: Fingerprint, hash: string) => Promise<FingerprintDbRecord>;

  // Retrieve a fingerprint by its hash
  getFingerprintByHash: (hash: string) => Promise<FingerprintDbRecord | null>;
}
interface DeferredLinkMethods {
  // Store a new deferred link
  storeDeferredLink: (deferredLink: DeferredLink) => Promise<void>;

  // Get a deferred link by fingerprint hash
  getDeferredLinkByFingerprintHash: (fingerprintHash: string) => Promise<DeferredLink | null>;

  // Delete a deferred link (called after successful match)
  deleteDeferredLink: (id: string) => Promise<void>;
}

interface DeferredLink {
  id: string;
  fingerprintHash: string;
  url: string;
  createdDate: Date;
}

Hooks

interface RelayHooks {
  // Called when a fingerprint match is found
  onMatchFound?: (deferredLink: DeferredLink, authCtx?: RelayAuthContext) => Promise<void> | void;
}

interface RelayAuthContext {
  userId: string | null;
}

The onMatchFound hook receives an optional authCtx parameter that contains authenticated user information. This is useful for attributing referrals directly to the signed-in user.

Storage Examples

JSON File Storage

Simple file-based storage for development:

import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";

const storePath = path.join(process.cwd(), "fingerprints.json");
const linkStorePath = path.join(process.cwd(), "links.json");

export const relayServer = createRelayServer({
  fingerprint: {
    methods: {
      storeFingerprint: async (fingerprint, hash) => {
        const id = new Date().getTime().toString();
        const record = { ...fingerprint, hash, id, createdDate: new Date() };

        const store = JSON.parse(readFileSync(storePath, "utf-8"));
        store[hash] = record;
        writeFileSync(storePath, JSON.stringify(store, null, 2));

        return record;
      },
      getFingerprintByHash: async (hash) => {
        const store = JSON.parse(readFileSync(storePath, "utf-8"));
        return store[hash] || null;
      },
    },
  },
  deferredLink: {
    methods: {
      storeDeferredLink: async (link) => {
        const store = JSON.parse(readFileSync(linkStorePath, "utf-8"));
        store.push(link);
        writeFileSync(linkStorePath, JSON.stringify(store, null, 2));
      },
      getDeferredLinkByFingerprintHash: async (hash) => {
        const store = JSON.parse(readFileSync(linkStorePath, "utf-8"));
        return store.find(link => link.fingerprintHash === hash) || null;
      },
      deleteDeferredLink: async (id) => {
        let store = JSON.parse(readFileSync(linkStorePath, "utf-8"));
        store = store.filter(link => link.id !== id);
        writeFileSync(linkStorePath, JSON.stringify(store, null, 2));
      },
    },
  },
});

PostgreSQL Storage

Production-ready database storage:

import { db } from "./database"; // Your database client

export const relayServer = createRelayServer({
  fingerprint: {
    methods: {
      storeFingerprint: async (fingerprint, hash) => {
        const result = await db.query(
          `INSERT INTO fingerprints (hash, data, created_at)
           VALUES ($1, $2, NOW())
           RETURNING *`,
          [hash, JSON.stringify(fingerprint)]
        );
        return result.rows[0];
      },
      getFingerprintByHash: async (hash) => {
        const result = await db.query(
          `SELECT * FROM fingerprints WHERE hash = $1`,
          [hash]
        );
        return result.rows[0] || null;
      },
    },
  },
  deferredLink: {
    methods: {
      storeDeferredLink: async (link) => {
        await db.query(
          `INSERT INTO deferred_links (id, fingerprint_hash, url, created_at)
           VALUES ($1, $2, $3, $4)`,
          [link.id, link.fingerprintHash, link.url, link.createdDate]
        );
      },
      getDeferredLinkByFingerprintHash: async (hash) => {
        const result = await db.query(
          `SELECT * FROM deferred_links
           WHERE fingerprint_hash = $1
           AND created_at > NOW() - INTERVAL '7 days'
           ORDER BY created_at DESC
           LIMIT 1`,
          [hash]
        );
        return result.rows[0] || null;
      },
      deleteDeferredLink: async (id) => {
        await db.query(
          `DELETE FROM deferred_links WHERE id = $1`,
          [id]
        );
      },
    },
  },
});

Prisma Storage

Using Prisma ORM:

import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

export const relayServer = createRelayServer({
  fingerprint: {
    methods: {
      storeFingerprint: async (fingerprint, hash) => {
        return await prisma.fingerprint.create({
          data: {
            hash,
            deviceManufacturer: fingerprint.deviceManufacturer,
            deviceModel: fingerprint.deviceModel,
            osName: fingerprint.osName,
            osVersion: fingerprint.osVersion,
            screenWidth: fingerprint.screenWidth,
            screenHeight: fingerprint.screenHeight,
            pixelRatio: fingerprint.pixelRatio,
            timeZone: fingerprint.timeZone,
            languageTags: fingerprint.languageTags,
            clipboardValue: fingerprint.clipboardValue,
            ipAddress: fingerprint.ipAddress,
          },
        });
      },
      getFingerprintByHash: async (hash) => {
        return await prisma.fingerprint.findUnique({
          where: { hash },
        });
      },
    },
  },
  deferredLink: {
    methods: {
      storeDeferredLink: async (link) => {
        await prisma.deferredLink.create({
          data: link,
        });
      },
      getDeferredLinkByFingerprintHash: async (hash) => {
        return await prisma.deferredLink.findFirst({
          where: {
            fingerprintHash: hash,
            createdDate: {
              gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), // Last 7 days
            },
          },
          orderBy: { createdDate: 'desc' },
        });
      },
      deleteDeferredLink: async (id) => {
        await prisma.deferredLink.delete({
          where: { id },
        });
      },
    },
  },
});

API Endpoints

The Relay server handler automatically handles these endpoints:

POST /relay/capture

Captures a device fingerprint and associates it with a URL.

Request Body:

{
  deferredLinkUrl: string;
  fingerprint: {
    deviceManufacturer: string | null;
    deviceModel: string | null;
    osName: string | null;
    osVersion: string | null;
    screenWidth: number;
    screenHeight: number;
    pixelRatio: number;
    timeZone: string | null;
    languageTags: string[];
    clipboardValue: string | null;
  }
}

Response:

{
  success: true
}

POST /relay/process

Processes a fingerprint and returns any matching deferred link.

Request Body:

{
  deviceManufacturer: string | null;
  deviceModel: string | null;
  osName: string | null;
  osVersion: string | null;
  screenWidth: number;
  screenHeight: number;
  pixelRatio: number;
  timeZone: string | null;
  languageTags: string[];
  clipboardValue: string | null;
}

Response:

{
  url: string | null // The matched URL, or null if no match
}

Setting Up the Handler

The handler function accepts an optional second parameter for authentication context:

handler: (request: Request, authCtx?: RelayAuthContext) => Promise<Response>

With Expo Router

// app/api/[...route]+api.ts
import { relayServer } from "@/libs/relay-server";
import { getUserFromSession } from "@/libs/auth"; // Your auth library

export const GET = (request: Request) => {
  return relayServer.handler(request);
};

export const POST = async (request: Request) => {
  // Extract user information from session/JWT
  const user = await getUserFromSession(request);

  // Pass auth context to the handler
  return relayServer.handler(request, {
    userId: user?.id ?? null,
  });
};

With Next.js API Routes

// pages/api/[...relay].ts
import { relayServer } from "@/libs/relay-server";
import type { NextApiRequest, NextApiResponse } from "next";

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  const request = new Request(`http://localhost:3000${req.url}`, {
    method: req.method,
    headers: new Headers(req.headers as HeadersInit),
    body: req.method !== "GET" ? JSON.stringify(req.body) : undefined,
  });

  const response = await relayServer.handler(request);
  const data = await response.json();

  res.status(response.status).json(data);
}

With Express

import express from "express";
import { relayServer } from "./relay-server";

const app = express();
app.use(express.json());

app.all("/api/relay/*", async (req, res) => {
  const request = new Request(`http://localhost:${port}${req.url}`, {
    method: req.method,
    headers: new Headers(req.headers as HeadersInit),
    body: req.method !== "GET" ? JSON.stringify(req.body) : undefined,
  });

  const response = await relayServer.handler(request);
  const data = await response.json();

  res.status(response.status).json(data);
});

Using Hooks

Hooks let you react to events in the Relay flow:

onMatchFound Hook

Called when a fingerprint match is found. The hook receives the matched deferred link and optional authentication context:

export const relayServer = createRelayServer({
  // ... storage config
  hooks: {
    onMatchFound: async (deferredLink, authCtx) => {
      console.log(`Match found for link: ${deferredLink.url}`);

      // Send analytics event
      await analytics.track('deferred_link_matched', {
        url: deferredLink.url,
        fingerprintHash: deferredLink.fingerprintHash,
        userId: authCtx?.userId,
      });

      // Update database
      await db.incrementMatchCount(deferredLink.url);

      // Send notification
      await notifyReferrer(deferredLink.url);
    },
  },
});

Using Auth Context for Referral Attribution

The auth context is particularly useful for referral programs. When a user signs up through a referral link, you can use the auth context to attribute the referral to the new user:

export const relayServer = createRelayServer({
  // ... storage config
  hooks: {
    onMatchFound: async (deferredLink, authCtx) => {
      // Extract referral code from the deferred link URL
      const url = new URL(deferredLink.url);
      const referralCode = url.searchParams.get('ref');

      if (!referralCode) {
        console.log('No referral code found in URL');
        return;
      }

      // If we have an authenticated user, attribute the referral
      if (authCtx?.userId) {
        // Find the referrer by their referral code
        const referrer = await db.users.findOne({ referralCode });

        if (referrer) {
          // Create a referral record
          await db.referrals.create({
            referrerId: referrer.id,
            referredUserId: authCtx.userId,
            referralCode: referralCode,
            matchedAt: new Date(),
          });

          // Award points or credits to the referrer
          await db.users.update(referrer.id, {
            referralPoints: referrer.referralPoints + 100,
          });

          // Send notification to the referrer
          await notifications.send(referrer.id, {
            title: 'New Referral!',
            body: 'Someone signed up using your referral code',
          });

          console.log(`Referral attributed: ${referrer.id} referred ${authCtx.userId}`);
        }
      } else {
        console.log('No authenticated user - referral will be attributed after login');
      }
    },
  },
});

This pattern allows you to:

  1. Track which user signed up through which referral link
  2. Award referral bonuses immediately upon signup
  3. Send real-time notifications to referrers
  4. Maintain a complete audit trail of referrals

Fingerprint Hashing

By default, Relay uses SHA-256 to hash fingerprints. You can provide a custom hashing function:

import crypto from "crypto";

export const relayServer = createRelayServer({
  fingerprint: {
    methods: {
      hashFingerprint: async (fingerprint) => {
        // Custom hashing logic
        const data = JSON.stringify({
          // Only include specific fields
          device: fingerprint.deviceModel,
          os: fingerprint.osVersion,
          screen: `${fingerprint.screenWidth}x${fingerprint.screenHeight}`,
        });

        return crypto.createHash("md5").update(data).digest("hex");
      },
      // ... other methods
    },
  },
});

Best Practices

Delete old deferred links to prevent stale matches:

getDeferredLinkByFingerprintHash: async (hash) => {
  const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);

  return await db.query(
    `SELECT * FROM deferred_links
     WHERE fingerprint_hash = $1
     AND created_at > $2
     LIMIT 1`,
    [hash, sevenDaysAgo]
  );
},

2. Index Database Columns

Index frequently queried columns for better performance:

CREATE INDEX idx_fingerprints_hash ON fingerprints(hash);
CREATE INDEX idx_links_fingerprint_hash ON deferred_links(fingerprint_hash);
CREATE INDEX idx_links_created_at ON deferred_links(created_at);

Delete deferred links after they're matched to prevent duplicate matches:

// The server automatically calls deleteDeferredLink after a match
// Make sure your implementation actually deletes the record

4. Monitor Performance

Track key metrics:

hooks: {
  onMatchFound: async (deferredLink) => {
    const matchTime = Date.now() - new Date(deferredLink.createdDate).getTime();

    await metrics.record('relay_match_time', matchTime);
    await metrics.increment('relay_matches_total');
  },
},

Next Steps

On this page