Server-side / Reverse Proxy
Use this pattern when you want your own domain to do the tracking work instead of loading https://adtarget.io/track.js in the browser.
For pre-join tracking, call the tracker-compatible endpoints through your reverse proxy. These endpoints do not use an API key because they mirror the browser tracker: websiteId is public, the request is rate-limited, and no business conversion is created.
Use the account-wide API key only for privileged backend events such as purchases, CRM events, or broker webhooks.
Never expose ADTARGET_API_KEY to browser code. Pageviews and invite clicks do not need it.
Endpoints
| Job | Endpoint | API key |
|---|---|---|
| Pageview / visit | POST https://adtarget.io/backend/track/init | No |
Deferred _fbp patch | POST https://adtarget.io/backend/track/update-fbp | No |
| Telegram invite click | GET https://adtarget.io/backend/track/invite | No |
| Purchase / custom backend event | POST https://adtarget.io/api/v1/events/conversion | Yes |
Architecture
End user -> yoursite.com (Cloudflare Worker)
|
| on every pageview:
| POST /backend/track/init
| no API key, pass websiteId + fbc/fbp/IP/UA/referrer
|
| on link click (/c/<slug>):
| GET /backend/track/invite
| no API key, redirect user to returned Telegram invite
|
| on payment/CRM webhook:
| POST /api/v1/events/conversion
| account API key, pass telegramUserId + event dataCloudflare Worker Example
Set up the Worker
// src/index.ts
export interface Env {
SITE_ORIGIN: string; // e.g. "https://origin.yoursite.com"
ADTARGET_WEBSITE_ID: string; // e.g. "atid_..."
ADTARGET_CHANNEL_ID?: string; // Optional Convex channel ID. Omit to use the site's default channel.
ADTARGET_API_KEY?: string; // Only for purchases/custom backend events.
}
const ADTARGET_TRACKING = "https://adtarget.io/backend";
const ADTARGET_API = "https://adtarget.io/api/v1";
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (url.pathname.startsWith("/c/")) {
return handleInvite(request, env);
}
return handlePageview(request, env);
},
};Track every pageview
async function handlePageview(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const cookies = parseCookies(request.headers.get("cookie") ?? "");
const tempId = cookies["adtarget_temp_id"] ?? crypto.randomUUID();
const sessionId = cookies["adtarget_session_id"] ?? crypto.randomUUID();
const fbp = cookies["_fbp"];
const fbc = cookies["_fbc"] ?? buildFbcFromFbclid(url.searchParams.get("fbclid"));
const visitorIp = getVisitorIp(request);
const visitorUserAgent = request.headers.get("user-agent") ?? "";
const trackPromise = fetch(`${ADTARGET_TRACKING}/track/init`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Adtarget-Client-IP": visitorIp,
"X-Adtarget-Client-User-Agent": visitorUserAgent,
},
body: JSON.stringify({
websiteId: env.ADTARGET_WEBSITE_ID,
tempId,
sessionId,
href: request.url,
landingPage: `${url.pathname}${url.search}`,
referrer: request.headers.get("referer") ?? undefined,
userAgent: visitorUserAgent,
fbc,
fbp,
gclid: url.searchParams.get("gclid") ?? undefined,
ttclid: url.searchParams.get("ttclid") ?? undefined,
sccid: url.searchParams.get("sccid") ?? url.searchParams.get("ScCid") ?? undefined,
utmSource: url.searchParams.get("utm_source") ?? undefined,
utmMedium: url.searchParams.get("utm_medium") ?? undefined,
utmCampaign: url.searchParams.get("utm_campaign") ?? undefined,
utmContent: url.searchParams.get("utm_content") ?? undefined,
utmTerm: url.searchParams.get("utm_term") ?? undefined,
}),
});
const originResponse = await fetch(env.SITE_ORIGIN + url.pathname + url.search, request);
// Tracking is best-effort. Never break the page if AdTarget is slow.
await Promise.race([
trackPromise.catch(() => null),
new Promise((resolve) => setTimeout(resolve, 500)),
]);
const response = new Response(originResponse.body, originResponse);
response.headers.append("Set-Cookie", buildSetCookie("adtarget_temp_id", tempId, 365 * 24 * 60 * 60));
response.headers.append("Set-Cookie", buildSetCookie("adtarget_session_id", sessionId, 30 * 60));
return response;
}
function buildSetCookie(name: string, value: string, maxAge: number): string {
return `${name}=${value}; Path=/; Max-Age=${maxAge}; SameSite=Lax; Secure`;
}
function getVisitorIp(request: Request): string {
return (
request.headers.get("cf-connecting-ip") ||
request.headers.get("true-client-ip") ||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
""
);
}
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}`;
}Patch _fbp when it appears later
If your page still loads Meta Pixel, _fbp can appear after the first pageview. Patch it on a subsequent request:
async function updateFbpIfPresent(request: Request, env: Env, tempId: string) {
const cookies = parseCookies(request.headers.get("cookie") ?? "");
const fbp = cookies["_fbp"];
if (!fbp) return;
await fetch(`${ADTARGET_TRACKING}/track/update-fbp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
websiteId: env.ADTARGET_WEBSITE_ID,
tempId,
fbp,
}),
}).catch(() => null);
}Handle invite clicks
async function handleInvite(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const cookies = parseCookies(request.headers.get("cookie") ?? "");
const tempId = cookies["adtarget_temp_id"] ?? crypto.randomUUID();
const sessionId = cookies["adtarget_session_id"] ?? crypto.randomUUID();
const target = new URL(`${ADTARGET_TRACKING}/track/invite`);
target.searchParams.set("website", env.ADTARGET_WEBSITE_ID);
target.searchParams.set("tid", tempId);
target.searchParams.set("sid", sessionId);
if (env.ADTARGET_CHANNEL_ID) {
target.searchParams.set("channel", env.ADTARGET_CHANNEL_ID);
}
const res = await fetch(target.toString(), {
redirect: "manual",
headers: {
"X-Adtarget-Client-IP": getVisitorIp(request),
},
});
const location = res.headers.get("location");
if (!location) {
return new Response("Invite generation failed", { status: 500 });
}
return Response.redirect(location, 302);
}Fire purchases from your backend
Purchases are different from pageviews: they are business events and must use the account-wide API key.
async function sendPurchaseToAdTarget(env: Env, args: {
telegramUserId: number;
orderId: string;
value: number;
currency: string;
email?: string;
}) {
if (!env.ADTARGET_API_KEY) throw new Error("Missing ADTARGET_API_KEY");
const res = await fetch(`${ADTARGET_API}/events/conversion`, {
method: "POST",
headers: {
"Authorization": `Bearer ${env.ADTARGET_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": `purchase:${args.orderId}`,
},
body: JSON.stringify({
telegramUserId: args.telegramUserId,
eventType: "Purchase",
value: args.value,
currency: args.currency,
pii: { email: args.email },
}),
});
if (!res.ok) {
throw new Error(`AdTarget purchase failed: ${res.status} ${await res.text()}`);
}
return res.json();
}Parity Matrix
| Tracker capability | Reverse proxy equivalent |
|---|---|
tempId UUID cookie (365 days) | Worker owns adtarget_temp_id cookie |
sessionId 30-min sliding cookie | Worker owns adtarget_session_id cookie |
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 X-Adtarget-Client-User-Agent and body userAgent |
| Client IP | Worker forwards X-Adtarget-Client-IP |
Deferred _fbp polling | Worker calls POST /backend/track/update-fbp when _fbp exists |
| Auto link rewriting (DOM) | Worker’s job: HTML-rewrite links or serve a redirect endpoint like /c/<slug> |
| CAPI dispatch on Telegram join | Unchanged: Telegram webhook -> AdTarget -> CAPI |
| Custom events / Purchases | POST /api/v1/events/conversion from your backend, with API key |
Troubleshooting EMQ
If Event Match Quality drops after switching to a reverse proxy:
- Forward the real client IP in
X-Adtarget-Client-IP. - Forward the real browser User-Agent in
X-Adtarget-Client-User-Agentand bodyuserAgent. - Read both
_fbpand_fbccookies from the incoming request. - Parse
fbclidfrom the URL and convert it tofbc:fb.1.<timestamp>.<fbclid>. - Pass PII on purchases when you have it.
Geo enrichment is not used for CAPI matching. The important server-side fields are IP, User-Agent, fbc/fbp, click IDs, and PII.
What about the Telegram webhook?
Nothing changes. Telegram still posts to AdTarget directly, AdTarget still runs the join attribution flow, and the resulting conversion still fires CAPI. The reverse proxy only replaces pre-join tracking: visits, fbp patching, and invite clicks.