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 plusrecent_clicks(row-level history) andclicks_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 bylimit(default 100, clamped 1–500).
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.
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.
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.
3. Per-contact click stream — which links did this recipient tap
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.
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.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.
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.- 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.
7. Node SDK — the three read calls
The cURL samples above map to three SDK methods one-to-one: the insights bundle asorbit.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
- 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.