Skip to main content

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 plus recent_clicks (row-level history) and clicks_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.
Use these samples when you wire link analytics into your own tooling — a nightly export, a CTR dashboard, a bot-traffic audit. All queries below are idempotent GETs against the same aggregate contract the dashboard reads. The endpoints are stable, but the verification signal threshold (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
The 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. 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
An unknown or expired link id returns 404. The second row above is a link-preview fetch — a real tap to this link alongside it, which is why the verified filter matters (Section 3).

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_score1.0 confident human, 0.0 confident bot, 0.3 missing user-agent. quality_reason is bot_user_agent, missing_user_agent, or null. A verified click means quality_score >= 0.5.
The verified filter lives at the read layer — raw clicks stay in the store — so every click ever recorded still counts toward the raw totals, and the filter applies retroactively to already-collected data. Filter client-side the same way the analytics layer does:
Node.js — filter a stats page down to verified clicks
A missing user-agent scores at 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.
  1. Send the broadcast. The campaign mints inline, so every link row already carries campaign_id and message_id. With auto-track on (whatsapp_auto_shorten_urls, default ON), the raw link in the body gets replaced before Meta sees it.
  2. Read raw totals with GET /api/v1/links/insights?campaign_id=cmp_lauch — summary shows total_clicks_window at roughly recipient-count-plus-gap, and clicks_by_day on the per-link drill spikes immediately after the send.
  3. Filter to verified. Read the same call again, but sum only verified_clicks_window / verified_clicks on top_links rows. On WhatsApp the preview UA (WhatsApp/…) classifies as bot, so the verified side removes it cleanly; the residual verified count is your human CTR.
  4. Export. Pull GET /api/v1/links/:id/stats per ranked link, walk recent_clicks (newest-first, 50-row bounded), and feed the rows !c.is_bot into your warehouse or CSV. Raw stays stored — the export keeps both counts, so a compliant audit can replay the filter later.
The dashboard tile renders this same pipeline; the API path is for when you want the verified export outside the dashboard.

Next steps