Counting how many people tap the website button in a WhatsApp campaign sounds like a redirect and a counter. In practice it's four separate problems. The link has to be in the template before Meta approves it. Preview fetchers and scanners hit the link without anyone tapping. The redirect must never wait for the database. And the hardest one: a template's link is shared by every campaign that sends it, so a click doesn't say which campaign it came from. This post builds each piece in TypeScript with tests, including the attribution shortcut we use in WAPGROWAI, a bug it had, and the design we'd use instead.
The snippets are small, self-contained versions of the patterns, and each was run against the cases shown.
Where the link goes: into the template, before approval
A WhatsApp template's website button has its URL fixed in the template, and the template is approved by Meta before you can send it. So click tracking can't be added at send time. The button has to point at your redirect when the template is submitted. Meta also offers dynamic URL buttons, where a {{1}} variable at the end of the URL is filled in on each send. We'll come back to those.
import { randomBytes } from "node:crypto"; export type TrackedLink = { code: string; templateId: string; buttonIndex: number; destination: string }; export type UrlButton = { type: "URL"; text: string; url: string }; export type Button = UrlButton | { type: string; text: string }; const newCode = () => randomBytes(6).toString("base64url"); const codeFrom = (url: string, base: string) => (url.startsWith(`${base}/r/`) ? url.slice(base.length + 3) : null); // Rewrite a template's static URL buttons to tracked links before it goes to // Meta for approval. Safe to run on every resubmission. export const applyTracking = (links: Map<string, TrackedLink>, templateId: string, buttons: Button[], base: string): Button[] => buttons.map((button, buttonIndex) => { if (button.type !== "URL") return button; const url = (button as UrlButton).url; // A {{1}} suffix is filled per send; a fixed short link can't carry it. if (url.includes("{{")) return button; // Already ours (the stored copy came back from a sync): never wrap twice. const existingCode = codeFrom(url, base); if (existingCode && links.has(existingCode)) return button; const current = [...links.values()].find((l) => l.templateId === templateId && l.buttonIndex === buttonIndex); const link = current ?? { code: newCode(), templateId, buttonIndex, destination: url }; // Same code, new destination: messages already sent follow the edit too. links.set(link.code, { ...link, destination: url }); return { ...button, url: `${base}/r/${link.code}` }; }); - One short code per (template, button position), reused on every resubmission. Re-running the rewrite must not create a second link, or the clicks split across two codes.
- Never wrap twice. If a sync from Meta copies the already-rewritten URL back into your stored template, the rewrite has to recognise its own link. Otherwise the next submission produces a redirect to a redirect.
- The destination lives in your database, not the template. That makes it editable: change the address and resubmit, and the same code points to the new page, including for messages already sent. It's useful when a sale page moves, and surprising if nobody expects it.
- Keep UTM tags in the destination. The redirect sends people to the stored URL exactly, so
utm_source=whatsappreaches the website's analytics untouched.
The redirect: count people, never block
import type { TrackedLink } from "./links.ts"; export type Click = { code: string; at: Date; recipient?: string }; // Fetches that aren't a person tapping: link previews, crawlers, scripts. // Deliberately not "whatsapp": the in-app browser can carry it on a real tap. const NON_HUMAN = /(facebookexternalhit|telegrambot|twitterbot|linkedinbot|slackbot|discordbot|googlebot|bingbot|applebot|bot\b|crawler|spider|curl|wget|python-requests|axios|node-fetch|okhttp|headlesschrome)/i; export const isHuman = (method: string, userAgent: string | undefined) => method === "GET" && Boolean(userAgent?.trim()) && !NON_HUMAN.test(userAgent ?? ""); // GET /r/:code[/:token]. Redirect first; recording the click must never delay // or break it. export const handleRedirect = ( links: Map<string, TrackedLink>, record: (click: Click) => Promise<void>, req: { method: string; code: string; token?: string; userAgent?: string }, fallback: string, now = new Date(), ): { status: number; location: string } => { const link = links.get(req.code); // Only stored destinations: a redirect that takes a URL from the request is an open redirect. if (!link) return { status: 302, location: fallback }; if (isHuman(req.method, req.userAgent)) { record({ code: link.code, at: now, recipient: req.token }).catch(() => { // Log it; a lost click is better than a visitor stuck on an error page. }); } return { status: 302, location: link.destination }; }; - Filter non-human fetches by user agent. Link-preview fetchers, crawlers, uptime checkers and scripts all hit links without anyone tapping. Keep the list conservative. One trap: don't filter on
WhatsApp. The in-app browser on a real tap can include it in its user agent, and filtering it drops genuine clicks. - Count only GET.
HEADrequests are previews and checks, not visits. Express answersHEADwith yourGEThandler unless you say otherwise, so they'll be counted if you don't check. - An empty user agent isn't a person. Phone browsers always send one.
- Redirect first, record in the background. A click recorder that's slow or down must not leave a customer on a spinner. A lost click is a smaller problem than a lost visitor.
- Only redirect to stored destinations. A redirect that reads the target from the request (
/r?to=…) is an open redirect, and a link on your domain becomes a phishing tool. - Unknown codes go somewhere useful, such as your site's home page, not a 404.
Attribution: one link, many campaigns
With one tracked link per template button, every campaign that sends that template shares it. A click arrives with a code and a time, and nothing else. WAPGROWAI splits clicks by time: each campaign owns the clicks from its send until the same template's next send.
export type CampaignSend = { id: string; templateId: string; sentAt: Date | null }; export type TimedClick = { templateId: string; at: Date }; // With one link per template, a click can only be matched to a campaign by time: // each sent campaign owns the clicks from its send until the template's next send. export const clicksByCampaign = (campaigns: CampaignSend[], clicks: TimedClick[]) => { const result = new Map<string, number>(campaigns.map((c) => [c.id, 0])); // Drafts and scheduled campaigns haven't sent anything; they must not cut a window short. const sent = campaigns.filter((c): c is CampaignSend & { sentAt: Date } => c.sentAt !== null); const byTemplate = new Map<string, (CampaignSend & { sentAt: Date })[]>(); for (const c of sent) byTemplate.set(c.templateId, [...(byTemplate.get(c.templateId) ?? []), c]); for (const list of byTemplate.values()) list.sort((a, b) => a.sentAt.getTime() - b.sentAt.getTime()); for (const click of clicks) { const list = byTemplate.get(click.templateId) ?? []; // The latest send at or before the click owns it. const owner = list.filter((c) => c.sentAt.getTime() <= click.at.getTime()).at(-1); if (owner) result.set(owner.id, (result.get(owner.id) ?? 0) + 1); } return result; }; // With a per-recipient token in the link, there is nothing to infer. export const clicksByToken = (sends: Map<string, { campaignId: string; contactId: string }>, tokens: string[]) => { const result = new Map<string, Set<string>>(); for (const token of tokens) { const send = sends.get(token); if (!send) continue; result.set(send.campaignId, (result.get(send.campaignId) ?? new Set()).add(send.contactId)); } return result; }; That's exact when campaigns using the same template don't overlap, and a best guess when they do. Clicks on a message sent last week, after a new campaign went out, count for the new one.
The bug: our version built the windows from every campaign that used the template, using the campaign's start time, else its publish time, else its creation time. A draft that never sent, or a cancelled campaign, still started a window at its creation time, and took the clicks that belonged to the campaign actually sent before it. The fix is in the code above: only campaigns that sent define windows.
Other things the time-window approach can't tell you:
- Who clicked. The link is the same for everyone.
- Unique clicks. Two taps by the same person are two clicks.
- Other senders. If an automation or a chatbot sends the same template, its clicks land in whichever campaign's window they fall in.
What we'd build instead: a token per recipient
A dynamic URL button fixes all three. Submit the template with a URL like https://go.example.com/r/abc123/{{1}}, and at send time fill {{1}} with a random token you store against that send: campaign, contact and message ID. The click then names its recipient, and there's nothing to infer.
The test shows the result: two taps from one person count as one person, and a contact who clicked in two different campaigns is credited to each. The cost is one stored token per message sent, and the rewrite has to handle URLs that already contain a variable, which ours currently skips.
What the tests check
- Rewrite: the static website button becomes a tracked link; the dynamic
{{1}}button is left alone; the quick-reply button is untouched. - Idempotency: resubmitting reuses the same code, and a URL that's already a tracked link isn't wrapped again.
- Editable destination: a new address keeps the code and updates the destination.
- Counting: a phone browser on GET counts;
facebookexternalhit,curl, an empty user agent and aHEADrequest don't. - Redirect: both the person and the preview bot are redirected to the stored URL, with UTM tags intact; only the person is recorded.
- Resilience: an unknown code falls back to the home page, and a recorder that throws doesn't break the redirect.
- Time windows: with sends on the 1st and 4th, clicks split two and two, and a click before the first send belongs to nobody.
- The draft case: a draft with no send time doesn't take clicks from the campaign before it.
- Tokens: two taps from one person count once, and the same person clicking in two campaigns is credited to each.
Checklist
- Rewrite website buttons to tracked links before submitting the template, and reuse codes on resubmission.
- Never wrap a tracked link twice.
- Keep destinations in your database, and decide whether edits should affect messages already sent.
- Count GET requests from people; skip previews, scripts,
HEADand empty user agents. - Redirect first and record the click in the background.
- Redirect only to stored destinations.
- If you attribute by time, build windows only from campaigns that actually sent.
- For exact attribution and unique clicks, use a dynamic URL button with a per-recipient token.
WAPGROWAI is built by DI Solutions, a custom software and AI development company. This series covers what we've learned building WAPGROWAI on the WhatsApp Cloud API. Related: end-to-end testing WhatsApp campaigns with a fake Graph API and routing WhatsApp quick-reply buttons to actions.



