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
- Load the tracker in
<head>, withasync, on every page an ad can land on. A tag manager that loads after the page adds seconds on mobile. - Serve it from your own subdomain — see First-Party Tracker Proxy. Ad blockers skip first-party requests far more often.
- Keep the query string on every redirect. A redirect from
wwwto 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=test123and check it survives to the page you land on. - 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:
- Reads the
fv_visitor_idcookie, 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. - Sends one
fv_ad_landingevent toPOST /v1/eventswith 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.
| Parameter | Platform |
|---|---|
gclid, gbraid, wbraid | Google Ads |
fbclid | Meta |
li_fat_id | |
twclid | X |
rdt_cid | |
oppref | ChatGPT Ads |
msclkid | Microsoft Ads |
ttclid | TikTok |
Step 3 — Set the cookie and send the event
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.
