> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Link tracking analytics API samples

> Worked samples for reading short-link analytics from the API — the tenant insights bundle (summary KPIs, top-links ranking, per-day timeline), per-link stats with row-level click history, verified-click filtering, and campaign scoping.

# 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](/guides/short-links-and-click-tracking#6-campaign-attribution-and-webhooks) 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.

```bash Tenant insights — one call for the KPI row theme={null}
curl "https://api.orbit.devotel.io/api/v1/links/insights?window_days=7&top_limit=3" \
  -H "X-API-Key: dv_test_sk_YOUR_KEY"
```

```json theme={null}
{
  "data": {
    "summary": {
      "total_links": 42,
      "total_clicks_window": 2310,
      "unique_visitors_window": 980,
      "avg_clicks_per_link": 55.0,
      "verified_clicks_window": 901,
      "verified_unique_visitors_window": 614
    },
    "top_links": [
      {
        "link_id": "link_01J4ZK8H2GQX7M9B3TNVAW4CDE",
        "code": "gH4kM2",
        "original_url": "https://example.com/promo?utm_campaign=may26",
        "campaign_id": "cmp_may26",
        "total_clicks": 721,
        "unique_visitors": 312,
        "verified_clicks": 251,
        "short_url": "https://api.orbit.devotel.io/l/gH4kM2"
      },
      {
        "link_id": "link_01J4ZKM4R2PQ9XTV7NHWAE33DC",
        "code": "x8Qk1p",
        "original_url": "https://example.com/checkout/start",
        "campaign_id": null,
        "total_clicks": 514,
        "unique_visitors": 201,
        "verified_clicks": 189,
        "short_url": "https://api.orbit.devotel.io/l/x8Qk1p"
      }
    ],
    "timeline": [
      { "date": "2026-08-21", "clicks": 312 },
      { "date": "2026-08-22", "clicks": 441 },
      { "date": "2026-08-27", "clicks": 388 }
    ]
  },
  "meta": { "request_id": "req_01J6PZ7HGN4WQ1TKB9CD2RXM" }
}
```

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.

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

```bash Per-link stats with click history theme={null}
curl "https://api.orbit.devotel.io/api/v1/links/link_01J4ZK8H2GQX7M9B3TNVAW4CDE/stats" \
  -H "X-API-Key: dv_test_sk_YOUR_KEY"
```

```json theme={null}
{
  "data": {
    "id": "link_01J4ZK8H2GQX7M9B3TNVAW4CDE",
    "code": "gH4kM2",
    "original_url": "https://example.com/promo?utm_campaign=may26",
    "campaign_id": "cmp_may26",
    "short_url": "https://api.orbit.devotel.io/l/gH4kM2",
    "recent_clicks": [
      {
        "id": "linkClick_01J6PQ0XW8D5RTN6BC2HXEVYKA",
        "ip_address": "203.0.113.44",
        "user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)",
        "referrer": null,
        "country": "US",
        "clicked_at": "2026-08-27T08:41:12.040Z",
        "is_bot": false
      },
      {
        "id": "linkClick_01J6PPVXQ1BZ9CTMK2Z9EQBFN",
        "ip_address": "198.51.100.7",
        "user_agent": "WhatsApp/2.26.11 A/B",
        "referrer": null,
        "country": "US",
        "clicked_at": "2026-08-27T08:38:40.129Z",
        "is_bot": true
      }
    ],
    "clicks_by_day": [
      { "date": "2026-08-27", "count": 12 },
      { "date": "2026-08-26", "count": 41 }
    ]
  },
  "meta": { "request_id": "req_01J6PZ8HQK4WQ1TKB9CD2RXP" }
}
```

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_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:

```javascript Node.js — filter a stats page down to verified clicks theme={null}
const res = await fetch(
  'https://api.orbit.devotel.io/api/v1/links/link_01J4ZK8H2GQX7M9B3TNVAW4CDE/stats',
  { headers: { 'X-API-Key': process.env.ORBIT_API_KEY } },
);
const { data } = await res.json();

const verified = data.recent_clicks.filter((c) => !c.is_bot);
// data.clicks_by_day still shows raw volume; verified() is your human count.
```

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.

```bash Insights scoped to one campaign theme={null}
curl "https://api.orbit.devotel.io/api/v1/links/insights?campaign_id=cmp_may26" \
  -H "X-API-Key: dv_test_sk_YOUR_KEY"
```

## 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

* [Short links with click tracking](/guides/short-links-and-click-tracking) — the mint side, branded domains, and the `short_link.click` webhook this read-side pairs with.
* [Short links cookbook](/guides/short-links-cookbook) — runnable recipes for mint, campaign, and contact read surfaces.
* [Links API reference](/api-reference/links) — request and response schemas for every endpoint named here.
