Link tracking analytics API samples
GET /api/v1/links is the mint surface; this page is the read side. Every tile on the Insights → Link tracking page is a render over three read endpoints:
GET /api/v1/links/insights— the tenant bundle: summary KPIs, a top-links ranking, and a per-day click timeline in one call.GET /api/v1/links/:id/stats— the per-link drill: the link record plusrecent_clicks(row-level history) andclicks_by_day(per-link aggregate).GET /api/v1/links/contacts/:contactId/clicks— the per-contact loop; the short links guide covers it, so this page focuses on the two read scopes above.
quality_score >= 0.5) is a convention you should filter on client-side, exactly as the samples below do.
1. What the panel computes — the tenant insights bundle
One call returns three blocks.window_days (default 7, clamped 1–90) controls how far back the click aggregates look; top_limit (default 10, clamped 1–50) sizes the ranking. Both bounds clamp rather than reject, so a query outside the spec returns the clamped envelope, not a 422.
Tenant insights — one call for the KPI row
summary block answers the operator’s KPI row: total_links minted against the tenant, total_clicks_window and verified_clicks_window in the requested window, unique_visitors_window and verified_unique_visitors_window (distinct clickers, raw then verified), and avg_clicks_per_link. A link with a null campaign_id in top_links (like the checkout start URL above) is exactly the un-attributed bucket Section 4 covers — the tenant rollup intentionally counts it.
2. Per-link drill — stats aggregate plus row-level click history
GET /api/v1/links/:id/stats is the per-link flip: the link record itself plus two views over its clicks. recent_clicks is the row-level history — newest-first, bounded to 50 rows, each carrying the click id, ip_address, user_agent, referrer, country, clicked_at, and an is_bot flag derived from the user-agent signature. clicks_by_day is the per-link aggregate — up to the 30 most recent day buckets, one { date, count } per day. This is the device-forensics view; the per-contact /contacts/:id/clicks endpoint deliberately drops IP and user-agent from its rows.
Per-link stats with click history
3. Verified-click filtering — read past the preview-fetches
Every click is recorded raw (IP, user-agent, referrer, country) and classified against a bot-signature list at write time. Two flags make the classification usable:is_bot— true when the user-agent matched a bot signature, or when the user-agent is missing entirely.quality_score—1.0confident human,0.0confident bot,0.3missing user-agent.quality_reasonisbot_user_agent,missing_user_agent, or null. A verified click meansquality_score >= 0.5.
Node.js — filter a stats page down to verified clicks
0.3 — below threshold — because a real browser essentially always sends one; a blank UA is overwhelmingly a script or a prefetch. Keep raw totals for audit; use verified for any CTR you report on.
4. Campaign scoping — what campaign_id excludes on purpose
Pass campaign_id on /links/insights and the whole bundle scopes to links minted with that id — the same campaign_id you set on POST /api/v1/links or an auto-shorten. Without it, the tenant rollup includes every link the tenant has minted, including the un-attributed bucket: one-off POST /api/v1/links calls made with no campaign_id, plus auto-shortened SMS / WhatsApp mints whose message attribution arrives later through the message id.
So a tenant total is never the sum of your per-campaign totals — the residual gap is exactly the un-attributed bucket, and a “campaigns sum less than the tenant total” reading is working as designed, not missing data. Use the campaign scope when you want a campaign-cut CTR; use the tenant scope for trend and audit. Both scopes are legitimate reads; name which one your dashboard is showing.
Insights scoped to one campaign
5. Worked example — WhatsApp send, inflated raw clicks, verified export
A WhatsApp broadcast with a promo link is the canonical inflation case: the platform link-preview fetch fires the redirect immediately after delivery, so the raw click count on every insight scope runs ahead of real taps by however many recipients were reached.- Send the broadcast. The campaign mints inline, so every link row already carries
campaign_idandmessage_id. With auto-track on (whatsapp_auto_shorten_urls, default ON), the raw link in the body gets replaced before Meta sees it. - Read raw totals with
GET /api/v1/links/insights?campaign_id=cmp_lauch— summary showstotal_clicks_windowat roughly recipient-count-plus-gap, andclicks_by_dayon the per-link drill spikes immediately after the send. - Filter to verified. Read the same call again, but sum only
verified_clicks_window/verified_clicksontop_linksrows. On WhatsApp the preview UA (WhatsApp/…) classifies as bot, so the verified side removes it cleanly; the residual verified count is your human CTR. - Export. Pull
GET /api/v1/links/:id/statsper ranked link, walkrecent_clicks(newest-first, 50-row bounded), and feed the rows!c.is_botinto your warehouse or CSV. Raw stays stored — the export keeps both counts, so a compliant audit can replay the filter later.
Next steps
- Short links with click tracking — the mint side, branded domains, and the
short_link.clickwebhook this read-side pairs with. - Short links cookbook — runnable recipes for mint, campaign, and contact read surfaces.
- Links API reference — request and response schemas for every endpoint named here.