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 the 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 click stream: every tracked click resolved back to one recipient contact, newest-first, capped by limit (default 100, clamped 1–500).
All three read scopes below carry a decoded response sample. The thresholds (recent_clicks newest-first bounded to 50 rows, clicks_by_day up to 30 day buckets, contact clicks limit 1–500) are server-side clamps, exactly as the dashboard reads them. 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.
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 5 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.
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 4). GET /api/v1/links/contacts/:contactId/clicks answers the CDP question the aggregate scopes leave open: for one recipient contact, which tracked links they clicked and when. Resolution walks the message-attribution chain — click → link → the message that carried the link → the recipient contact — so an anonymous tap on a link that was never carried by a send never lands in a contact’s history. Rows are newest-first; limit (default 100, clamped 1–500) bounds the page. This is the engagement view, so IP and user-agent are deliberately dropped — the per-link drill above is the device-forensics surface.
Each row carries the resolved link (link_id, code, original_url, campaign_id), the carrier message_id, and the click’s referrer/country/clicked_at — enough to attribute contact-level engagement per link and per campaign without ever exposing the raw device fingerprint of the reader.

4. 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.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. The quality_score >= 0.5 filter is a client-side convention, not a server query parameter — the API returns raw clicks and the classification fields, and you apply the threshold in your pipeline exactly as the snippet above does. That keeps the verdicts replayable: if you ever tighten or loosen the threshold, re-filtering the same stored raw data reproduces the report without re-ingesting clicks.

5. 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

6. 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.

7. Node SDK — the three read calls

The cURL samples above map to three SDK methods one-to-one: the insights bundle as orbit.links.list() over the tenant, per-link stats as orbit.links.getClickEvents(id), and the per-contact stream as a plain authenticated GET through the client’s low-level request() helper. Keep the quality_score >= 0.5 filter client-side in the SDK path the same way the cURL samples do.
Node SDK — read the three scopes
list()/iter() cover the ranked tenant list; getClickEvents() maps to the same click rows the cURL drill above decodes (recent_clicks); and orbit.request keeps the per-contact route reachable while the SDK exposes no named method for it. Authenticate with the same API key; the SDK base URL already ends at /api/v1, so the path starts at /links/….

Next steps