Skip to Content
Track your Telegram conversions with Meta Ads - Get started in minutes!
API ReferenceServer-side / Reverse Proxy

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 capabilityServer-side equivalent
tempId UUID cookie (365 days)Worker reads adtarget_temp_id cookie + echoes Set-Cookie from API response
sessionId 30-min sliding cookieSame pattern, with adtarget_session_id
Reads _fbp / _fbc cookiesWorker reads them from the incoming request
Parses fbclid / gclid / ttclid / UTM paramsWorker parses url.searchParams
Reads document.referrerWorker reads Referer header
navigator.userAgentWorker forwards user-agent header
Client IP via Vercel geoWorker forwards cf-connecting-ip via X-Adtarget-Client-IP
screen.width/height, language, timezoneOptional body fields (low impact on CAPI quality)
Deferred _fbp pollingCall 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 supportNot needed: every pageview is a Worker request
CAPI dispatch on Telegram joinUnchanged: Telegram webhook → AdTarget → CAPI
Custom events / PurchasesPOST /events/conversion from your CRM/backend

Troubleshooting EMQ (Meta Event Match Quality)

If your EMQ drops after switching to server-side:

  1. Forward the real client IP. Use X-Adtarget-Client-IP or clientIp body field — not the Worker/proxy IP.
  2. Forward the real User-Agent. X-Adtarget-Client-User-Agent or userAgent body field.
  3. Read both _fbp and _fbc cookies from the incoming request and pass them on every conversion.
  4. Parse fbclid from the URL and convert to fbc format: fb.1.<timestamp>.<fbclid>.
  5. 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).

Last updated on