Server-side / Reverse Proxy
The AdTarget REST API has 1:1 parity with the tracker JS. You can replace track.js entirely with a reverse proxy that calls the API on every relevant request.
This guide shows the full pattern using Cloudflare Workers, but the same approach works for Nginx, Next.js middleware, Vercel Edge functions, or any HTTP-aware proxy.
When to use this
- Privacy: you don’t want third-party JS on your site.
- Anti-blocker: ad blockers ignore your own domain.
- Performance: skip the deferred tracker round-trip.
- Custom routing: you want full control over what becomes a tracked event.
Architecture
End user ─HTTP─▶ yoursite.com (Cloudflare Worker)
│
├─ on every pageview:
│ POST /api/v1/events/visit
│ └─ pass fbc/fbp/IP/UA/referrer extracted from req
│
├─ on link click (/c/<channel>):
│ POST /api/v1/events/invite
│ └─ redirect end-user to inviteLink
│
└─ on Telegram webhook (still hits AdTarget directly):
webhook → handleChatMember → conversion → CAPI ✓Cloudflare Worker example
Set up the Worker
// src/index.ts
export interface Env {
ADTARGET_API_KEY: string;
SITE_ORIGIN: string; // e.g. "https://origin.yoursite.com"
}
const ADTARGET_API = "https://adtarget.io/api/v1";
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// Channel redirect: /c/<channelId>?tid=<tempId>
if (url.pathname.startsWith("/c/")) {
return handleInvite(request, env);
}
// Proxy the origin, then track the pageview asynchronously.
return handlePageview(request, env);
},
};Track every pageview
async function handlePageview(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// Read cookies from the incoming request
const cookies = parseCookies(request.headers.get("cookie") ?? "");
const tempId = cookies["adtarget_temp_id"] ?? crypto.randomUUID();
const sessionCookie = cookies["adtarget_session_id"];
const fbp = cookies["_fbp"];
const fbc = cookies["_fbc"] ?? buildFbcFromFbclid(url.searchParams.get("fbclid"));
// Fire-and-forget: don't block the user's request.
const trackPromise = fetch(`${ADTARGET_API}/events/visit`, {
method: "POST",
headers: {
Authorization: `Bearer ${env.ADTARGET_API_KEY}`,
"Content-Type": "application/json",
"X-Adtarget-Client-IP": request.headers.get("cf-connecting-ip") ?? "",
"X-Adtarget-Client-User-Agent": request.headers.get("user-agent") ?? "",
},
body: JSON.stringify({
href: request.url,
referrer: request.headers.get("referer"),
tempId,
clientSessionId: sessionCookie,
fbc, fbp,
gclid: url.searchParams.get("gclid"),
ttclid: url.searchParams.get("ttclid"),
utmSource: url.searchParams.get("utm_source"),
utmMedium: url.searchParams.get("utm_medium"),
utmCampaign: url.searchParams.get("utm_campaign"),
utmContent: url.searchParams.get("utm_content"),
utmTerm: url.searchParams.get("utm_term"),
}),
});
// Proxy the request to your origin
const originResponse = await fetch(env.SITE_ORIGIN + url.pathname + url.search, request);
// Wait for tracking (with timeout) so we can echo cookies on the response
let setCookies: string[] = [];
try {
const trackResponse = await Promise.race([
trackPromise,
new Promise<Response>((_, rej) => setTimeout(() => rej("timeout"), 500)),
]);
if (trackResponse.ok) {
const trackData = await trackResponse.json<{
cookies: { tempId: any; session: any }
}>();
setCookies = [
buildSetCookie(trackData.cookies.tempId),
buildSetCookie(trackData.cookies.session),
];
}
} catch {
// Best-effort: never break the user request because tracking failed.
}
const response = new Response(originResponse.body, originResponse);
setCookies.forEach((c) => response.headers.append("Set-Cookie", c));
return response;
}
function buildSetCookie(hint: { name: string; value: string; maxAge: number; sameSite: string }): string {
return `${hint.name}=${hint.value}; Path=/; Max-Age=${hint.maxAge}; SameSite=${hint.sameSite}; Secure`;
}
function parseCookies(header: string): Record<string, string> {
const out: Record<string, string> = {};
for (const part of header.split(";")) {
const [k, ...rest] = part.trim().split("=");
if (k) out[k] = rest.join("=");
}
return out;
}
function buildFbcFromFbclid(fbclid: string | null): string | undefined {
if (!fbclid) return undefined;
return `fb.1.${Date.now()}.${fbclid}`;
}Handle invite clicks
async function handleInvite(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const channel = url.pathname.replace("/c/", ""); // e.g. "-1001234567890"
const tempId = new URL(request.url).searchParams.get("tid")
?? parseCookies(request.headers.get("cookie") ?? "")["adtarget_temp_id"]
?? crypto.randomUUID();
const res = await fetch(`${ADTARGET_API}/events/invite`, {
method: "POST",
headers: {
Authorization: `Bearer ${env.ADTARGET_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ tempId, channelId: channel }),
});
if (!res.ok) {
return new Response("Invite generation failed", { status: 500 });
}
const { inviteLink } = await res.json<{ inviteLink: string }>();
return Response.redirect(inviteLink, 302);
}Parity matrix
| Tracker capability | Server-side equivalent |
|---|---|
tempId UUID cookie (365 days) | Worker reads adtarget_temp_id cookie + echoes Set-Cookie from API response |
sessionId 30-min sliding cookie | Same pattern, with adtarget_session_id |
Reads _fbp / _fbc cookies | Worker reads them from the incoming request |
Parses fbclid / gclid / ttclid / UTM params | Worker parses url.searchParams |
Reads document.referrer | Worker reads Referer header |
navigator.userAgent | Worker forwards user-agent header |
| Client IP via Vercel geo | Worker forwards cf-connecting-ip via X-Adtarget-Client-IP |
screen.width/height, language, timezone | Optional body fields (low impact on CAPI quality) |
Deferred _fbp polling | Call POST /events/update-fbp on a subsequent request when fbp arrives |
| Auto link rewriting (DOM) | Worker’s job: HTML-rewrite outbound links OR serve a redirect endpoint like /c/<channel> |
| SPA support | Not needed: every pageview is a Worker request |
| CAPI dispatch on Telegram join | Unchanged: Telegram webhook → AdTarget → CAPI |
| Custom events / Purchases | POST /events/conversion from your CRM/backend |
Troubleshooting EMQ (Meta Event Match Quality)
If your EMQ drops after switching to server-side:
- Forward the real client IP. Use
X-Adtarget-Client-IPorclientIpbody field — not the Worker/proxy IP. - Forward the real User-Agent.
X-Adtarget-Client-User-AgentoruserAgentbody field. - Read both
_fbpand_fbccookies from the incoming request and pass them on every conversion. - Parse
fbclidfrom the URL and convert tofbcformat:fb.1.<timestamp>.<fbclid>. - Pass hashed PII when you have it (email, phone, name) — biggest single EMQ booster.
Geo enrichment (country/city/region) is not used for CAPI matching — only the IP is. Don’t worry if country is empty in the dashboard for server-side traffic.
What about the Telegram webhook?
Nothing changes. Telegram still posts to AdTarget directly, AdTarget still runs handleChatMember, and the resulting conversion still fires Meta CAPI. The Worker only replaces the pre-join tracking (visits + invite clicks).