FullVision
Tracker Setup

Server-Side Click Capture

Record every ad click id on your server, so conversions sent back to ad platforms keep their click even when the browser tracker never runs

FullVision sends your conversions back to Google, Meta, LinkedIn, X, Reddit and ChatGPT Ads with the click id of the ad that brought the person in. The ad platform matches that click id far more reliably than an email.

The browser tracker reads the click id from the landing page URL. That only works when the tracker runs and sends its pageview. On real ad traffic it often doesn't: people close the page before the script finishes loading, and in-app browsers (X, LinkedIn, Facebook) close the page before the pageview is sent. On one customer's X campaign, the tracker recorded fewer than half of the real ad visitors that reached the site.

Server-side click capture records the click id when your server receives the landing request, before any JavaScript runs.

Who needs this

You do if you run paid ads and send conversions back to the ad platforms, especially on mobile-heavy channels (X, Meta, Reddit, TikTok, LinkedIn).

Start with the checklist below if you haven't yet. Most lost click ids come from a setup issue, not from the browser.

Checklist first

  1. Load the tracker in <head>, with async, on every page an ad can land on. A tag manager that loads after the page adds seconds on mobile.
  2. Serve it from your own subdomain — see First-Party Tracker Proxy. Ad blockers skip first-party requests far more often.
  3. Keep the query string on every redirect. A redirect from www to the apex, a trailing-slash rule, or a vanity URL that drops ?gclid=… loses the click id before any tool can see it. Test each ad's final URL with a fake ?gclid=test123 and check it survives to the page you land on.
  4. Don't let a consent banner or tag manager strip URL parameters.

How it works

On every page request that carries a click id parameter, your server or edge:

  1. Reads the fv_visitor_id cookie, or creates one if the visitor has none. The browser tracker adopts this cookie, so the click and everything the person does afterwards belong to the same visitor.
  2. Sends one fv_ad_landing event to POST /v1/events with the click id in its properties. Send it in the background, so the page is never delayed.

FullVision's conversion uploads read fv_ad_landing events next to browser pageviews. When the person later signs up or pays, the click id is there even if the tracker never sent that first pageview.

Step 1 — Create a secret key

Create a secret key (sk_) with the events:write scope in your workspace settings, and store it as a server-side secret. Never ship it to the browser.

Step 2 — Recognise click ids

Capture these URL parameters, with their exact case. The ad platforms reject a click id whose case changed.

ParameterPlatform
gclid, gbraid, wbraidGoogle Ads
fbclidMeta
li_fat_idLinkedIn
twclidX
rdt_cidReddit
opprefChatGPT Ads
msclkidMicrosoft Ads
ttclidTikTok

The cookie must be readable by the tracker: no HttpOnly, Path=/, a one-year Max-Age, and Domain set to your workspace's cookie domain (for example example.com, so it covers www. and your app subdomain). The value is fv_ followed by 32 lowercase hex characters.

The event:

{
  "event_name": "fv_ad_landing",
  "visitor_id": "fv_3f2a…",
  "idempotency_key": "fv_ad_landing:<sha-256 hex of the visitor id and click ids>",
  "properties": {
    "twclid": "2b46tq8cpig59rl1xsqwfa2lrz",
    "page_url": "https://example.com/pricing?utm_source=twitter&twclid=…",
    "referrer": "https://t.co/",
    "user_agent": "Mozilla/5.0 …",
    "utm_source": "twitter",
    "utm_medium": "paid_social",
    "utm_campaign": "launch"
  }
}

The idempotency_key makes a reload or a back-button visit count once. Hash it: the API caps the key at 128 characters, and a Google or Meta click id alone can be over 100.

Cloudflare Workers

const CLICK_PARAMS = ["gclid", "gbraid", "wbraid", "fbclid", "li_fat_id",
  "twclid", "rdt_cid", "oppref", "msclkid", "ttclid"];
const COOKIE_DOMAIN = "example.com";

async function landingKey(visitorId: string, clicks: Record<string, string>) {
  const digest = await crypto.subtle.digest("SHA-256",
    new TextEncoder().encode(JSON.stringify([visitorId, clicks])));
  return "fv_ad_landing:" + [...new Uint8Array(digest)]
    .map((b) => b.toString(16).padStart(2, "0")).join("");
}

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext) {
    const response = await fetch(request); // or env.ASSETS.fetch(request)
    const url = new URL(request.url);
    const clicks = Object.fromEntries(
      CLICK_PARAMS.map((p) => [p, url.searchParams.get(p)?.trim()])
        .filter(([, v]) => v),
    );
    const isPage = request.method === "GET" &&
      request.headers.get("sec-fetch-dest") !== "iframe" &&
      (request.headers.get("accept") ?? "").includes("text/html");
    if (!isPage || Object.keys(clicks).length === 0) return response;

    const existing = request.headers.get("cookie")
      ?.match(/(?:^|;\s*)fv_visitor_id=([^;]+)/)?.[1];
    const visitorId = existing ??
      "fv_" + crypto.randomUUID().replace(/-/g, "");

    ctx.waitUntil(landingKey(visitorId, clicks).then((key) => fetch("https://db.fullvision.io/v1/events", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${env.FV_SECRET_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        event_name: "fv_ad_landing",
        visitor_id: visitorId,
        idempotency_key: key,
        properties: {
          ...clicks,
          page_url: url.toString(),
          referrer: request.headers.get("referer") ?? "",
          user_agent: request.headers.get("user-agent") ?? "",
          utm_source: url.searchParams.get("utm_source") ?? "",
          utm_medium: url.searchParams.get("utm_medium") ?? "",
          utm_campaign: url.searchParams.get("utm_campaign") ?? "",
        },
      }),
    })).catch(() => {}));

    if (existing) return response;
    const headers = new Headers(response.headers);
    headers.append("set-cookie", `fv_visitor_id=${visitorId}; Max-Age=31536000; ` +
      `Path=/; Domain=${COOKIE_DOMAIN}; SameSite=Lax; Secure`);
    return new Response(response.body, { status: response.status, headers });
  },
};

If the page is cached at the edge, run this code before the cache (a Worker route, or run_worker_first with Workers static assets). A cached response that skips your code skips the capture.

Next.js (middleware)

// middleware.ts
import { NextResponse, type NextFetchEvent, type NextRequest } from "next/server";

const CLICK_PARAMS = ["gclid", "gbraid", "wbraid", "fbclid", "li_fat_id",
  "twclid", "rdt_cid", "oppref", "msclkid", "ttclid"];

async function landingKey(visitorId: string, clicks: Record<string, string>) {
  const digest = await crypto.subtle.digest("SHA-256",
    new TextEncoder().encode(JSON.stringify([visitorId, clicks])));
  return "fv_ad_landing:" + [...new Uint8Array(digest)]
    .map((b) => b.toString(16).padStart(2, "0")).join("");
}

export function middleware(request: NextRequest, event: NextFetchEvent) {
  const url = request.nextUrl;
  const clicks = Object.fromEntries(
    CLICK_PARAMS.map((p) => [p, url.searchParams.get(p)?.trim()]).filter(([, v]) => v),
  );
  const response = NextResponse.next();
  if (Object.keys(clicks).length === 0) return response;

  const existing = request.cookies.get("fv_visitor_id")?.value;
  const visitorId = existing ?? "fv_" + crypto.randomUUID().replace(/-/g, "");
  if (!existing) {
    response.cookies.set("fv_visitor_id", visitorId, {
      maxAge: 31536000, path: "/", domain: "example.com", sameSite: "lax", secure: true,
    });
  }
  event.waitUntil(landingKey(visitorId, clicks).then((key) => fetch("https://db.fullvision.io/v1/events", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.FV_SECRET_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      event_name: "fv_ad_landing",
      visitor_id: visitorId,
      idempotency_key: key,
      properties: {
        ...clicks,
        page_url: url.toString(),
        referrer: request.headers.get("referer") ?? "",
        user_agent: request.headers.get("user-agent") ?? "",
      },
    }),
  })).catch(() => {}));
  return response;
}

export const config = { matcher: ["/((?!_next/|api/|.*\\..*).*)"] };

Any other server

The same three steps apply to Express, Rails, Django, Laravel or a reverse proxy: read the click parameters from the request, set the cookie when it is missing, and post the event without making the visitor wait for it.

Step 4 — Check it works

Open one of your landing pages with a fake click id, for example https://example.com/?twclid=fv-test-123, then look for an fv_ad_landing event with that twclid in Events in the dashboard. Your browser should now carry an fv_visitor_id cookie, and the tracker's next pageview should use the same value.

fv_ad_landing does not count as a pageview. Traffic, sessions and channel reports are unchanged; only the conversion uploads read it.

On this page