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

# Call tracking & dynamic number insertion (DNI)

> Bind pools of tracking DIDs to marketing sources and UTM campaigns, swap the displayed number on your site, and read inbound-call attribution per source.

# Call tracking & dynamic number insertion

Call tracking answers the question a click-level analytics tool cannot: **which marketing source made the phone ring**. DNI (dynamic number insertion) is the mechanism: your site shows a different inbound number to visitors from different campaigns, so every inbound call lands on a DID that maps back to exactly one source.

On Orbit the loop has three parts — a **tracking pool** (a named container that binds a set of tracking DIDs to a source, campaign, and match rules), a **resolve endpoint** the website snippet calls to pick the right DID for the current visitor, and the **call attribution** report that rolls inbound calls on tracking DIDs up by source and campaign.

This complements the channel-level [Agent ROI attribution](/guides/agent-roi-attribution) guide, which prices an agent's resolved outcomes against its model-call cost. Call tracking is the offline→online attribution join for the inbound voice channel: nothing is written to the call ledger, and the source mapping is resolved at read time.

Tracking DIDs are **inbound-only**. They terminate inbound calls; outbound (MT) voice and SMS never route through them. Reads need the `numbers:read` scope; creating pools and assigning DIDs need `numbers:write`. Pool config is stored in organization settings (JSONB), so it survives without any data migration and stays isolated to your organization.

## 1. When to use it

Use a tracking pool when paid or organic traffic drives inbound calls and you need per-source attribution on the phone channel: Google Ads PPC clicks that end in a call, a partner or affiliate link that dials, an email campaign with a click-to-call button. Skip it for your one permanent main line — DNI only helps when different visitors should see different numbers.

## 2. Create a tracking pool

Create one pool per source you want to attribute. The pool carries a `label`, the `source` it attributes calls to, an optional `campaign` and `medium`, and `match` rules that decide which visitors see its numbers.

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/numbers/tracking-pools \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "label": "Google Ads — Brand",
    "source": "google",
    "campaign": "brand-search",
    "medium": "cpc",
    "match": {
      "utmSource": "google",
      "referrerContains": "google.com",
      "requireGclid": true
    }
  }'
```

| Field          | Required | Description                                                                                                                |
| -------------- | -------- | -------------------------------------------------------------------------------------------------------------------------- |
| `label`        | yes      | Operator-facing name, up to 120 characters.                                                                                |
| `source`       | yes      | The marketing source inbound calls attribute to (e.g. `google`).                                                           |
| `campaign`     | no       | Campaign name reported alongside the source.                                                                               |
| `medium`       | no       | Marketing medium reported alongside the source (e.g. `cpc`).                                                               |
| `match`        | no       | Rule set the resolver scores a visitor against — every defined condition must hold. Undefined conditions are "don't care". |
| `swapStrategy` | no       | `sticky` (default) pins each session to one DID deterministically; `fixed` shows the pool's first DID to everyone.         |
| `enabled`      | no       | Defaults to `true`. Disabled pools are skipped by the resolver but retained for history.                                   |

A pool with no `match` rules matches every visitor — a catch-all fallback for any traffic not claimed by a more specific pool. When several pools match, the resolver picks the one with the most defined match conditions; ties break deterministically by creation time and id.

Match rules available per pool:

| Rule                                                  | Matches when                                                                         |
| ----------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `utmSource` / `utmMedium` / `utmCampaign` / `utmTerm` | Case-insensitive exact match on the corresponding UTM parameter.                     |
| `referrerContains`                                    | The document referrer contains this substring, case-insensitive (e.g. `google.com`). |
| `requireGclid`                                        | A Google click id (`gclid`) is present on the request.                               |

Update a pool later with `PATCH`, and list or inspect with `GET`:

```bash theme={null}
curl https://api.orbit.devotel.io/api/v1/numbers/tracking-pools \
  -H "X-API-Key: dv_live_sk_..."
```

## 3. The DNI snippet contract

The `GET /numbers/tracking-pools/resolve` endpoint is what your site's swap snippet calls. Pass the visitor's UTM parameters, referrer, and a stable session key (any cookie/session id you already have); it returns the DID to display, the pool that produced it, and the attributed source and campaign. When nothing matches it returns `number: null` and the page keeps its default number.

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/numbers/tracking-pools/resolve?utm_source=google&utm_medium=cpc&gclid=abc123&session=s_xyz" \
  -H "X-API-Key: dv_live_sk_..."
```

Response:

```json theme={null}
{
  "data": {
    "number": "+14155550142",
    "poolId": "trackingPool_9k2m8p",
    "source": "google",
    "campaign": "brand-search"
  }
}
```

The query parameters — all optional, snake\_case: `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `referrer`, `gclid`, `session`.

Before — a static site number:

```html theme={null}
<a href="tel:+14155550100">Call us: +1 (415) 555-0100</a>
```

After — swap by referrer/UTM/session:

```html theme={null}
<a id="call-track-link" href="tel:+14155550100">Call us: +1 (415) 555-0100</a>
<script>
  (async () => {
    const el = document.getElementById("call-track-link");
    const session =
      // Prefer your existing session cookie; fall back to generating one
      // && persist it so the sticky strategy sees a stable key.
      crypto.randomUUID();
    const url = new URL(location.href);
    const params = new URLSearchParams({
      utm_source: url.searchParams.get("utm_source") ?? "",
      utm_medium: url.searchParams.get("utm_medium") ?? "",
      utm_campaign: url.searchParams.get("utm_campaign") ?? "",
      utm_term: url.searchParams.get("utm_term") ?? "",
      referrer: document.referrer,
      gclid: url.searchParams.get("gclid") ?? "",
      session,
    });
    try {
      // Call resolve from your backend or an endpoint that holds the API key;
      // do not ship a secret API key to the browser.
      const res = await fetch(
        "/orbit/dni-resolve?" + params.toString()
      );
      const { data } = await res.json();
      if (data.number) {
        el.href = "tel:" + data.number;
        el.textContent = "Call us: " + data.number;
      }
    } catch {
      // Network or API failure — keep the page's default number.
    }
  })();
</script>
```

Proxy `/orbit/dni-resolve` from your own backend to `GET /numbers/tracking-pools/resolve` with your API key, or use a publishable flow that adds the key server-side — an API key must never reach the browser. On failure or no-match, the snippet leaves the default number untouched.

## 4. Assign tracking DIDs to the pool

A pool with no DIDs never swaps. Assign owned DIDs with `POST /numbers/tracking-pools/:id/assign`:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/numbers/tracking-pools/trackingPool_9k2m8p/assign \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{ "phoneNumber": "+14155550142" }'
```

The assignment rules:

* The number must be in E.164 (`+14155550142`) — anything else returns `422`.
* The number must belong to your organization; an unknown or foreign DID returns `404` so you cannot enumerate another org's numbers.
* A DID belongs to at most one pool. Assigning it to a new pool **moves** it out of the old pool atomically, so one number always maps to exactly one source.
* Remove a DID with `POST /numbers/tracking-pools/:id/unassign` and the same body shape.
* Buy the DIDs first with the normal purchase flow — see [Buy and provision numbers](/guides/buy-numbers). Toll-free and local voice-capable DIDs both work as tracking numbers.

## 5. Read attribution

Inbound calls to tracking DIDs show up on `GET /insights/call-attribution`, grouped by source and campaign with per-source call, answered, and duration aggregates:

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/insights/call-attribution?days=30" \
  -H "X-API-Key: dv_live_sk_..."
```

| Parameter     | Description                                                                 |
| ------------- | --------------------------------------------------------------------------- |
| `days`        | Lookback window, 1–365 days. Defaults to 30.                                |
| `from` / `to` | Explicit ISO window; overrides `days`. Over-long windows clamp to 365 days. |
| `pool_id`     | Restrict the summary to one pool.                                           |

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/insights/call-attribution?from=2026-08-01&to=2026-08-24" \
  -H "X-API-Key: dv_live_sk_..."
```

The response carries `totals` (calls, answered, per-DID rows, distinct sources) plus a `sources` array — one row per source/campaign pair — sorted by call volume. The same data renders under **Insights → Call attribution** in the dashboard. Because the join is read-time (DID → pool → source), a call to a DID that is not assigned to any pool does not attribute, and deleting a pool keeps history for the remaining pools but drops that source row going forward.

## 6. Constraints

* **Inbound-only.** Tracking DIDs terminate inbound calls. No outbound (MT) voice or SMS path is created by a pool, and assignment only ever gates on number ownership.
* **Storage.** Pools live in your organization's settings JSONB, so no migration is needed and sibling sub-accounts never share pools.
* **Scope.** Reads require `numbers:read`; create, update, delete, assign, and unassign require `numbers:write`. Resolve reads through `numbers:read`.
* **Quotas.** Up to 200 pools per organization and 1,000 tracking DIDs per pool; over-quota writes return `429`. Delete an unused pool or move DIDs to unblock.
* **One pool per DID.** A DID maps to one source by design, so never assign the same DID to parallel pools expecting both to attribute.

## 7. Troubleshooting

| Symptom                              | Check                                                                                                                                                                                                                                                                                                                                                                                                         |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The page never swaps the number.     | The pool has no tracking DIDs — assign at least one. The pool is disabled (`enabled: false`). No pool's match rules hold for the visitor — `/resolve` returns `number: null` and the snippet keeps the default. The snippet's `session` key changes per call, so `sticky` re-hashes. `gclid`/UTM parameters are lost between the landing page and the page running the snippet — capture them at first touch. |
| Attribution is empty.                | `days`/`from`/`to` does not cover the call window. The calls dialed the *default* site number, not a pool's DID. Assignments happened after the calls (the join is read-time, so re-assign and the historical calls attribute correctly).                                                                                                                                                                     |
| A session sees a stale or wrong DID. | The pool was edited or the DID was moved between page loads. Under `sticky`, the same session hashes to the same DID from whatever the pool currently holds — the resolver is deterministic per call, so a moved DID changes what an old session sees on the next load.                                                                                                                                       |
| `422` on assign.                     | `phoneNumber` is not E.164 — pass `+14155550142` with no spaces or punctuation.                                                                                                                                                                                                                                                                                                                               |
| `404` on assign.                     | The DID is not owned by your organization (or the pool id is wrong) — buy the DID first or check the id.                                                                                                                                                                                                                                                                                                      |
| `429` on create/assign.              | Pool-count or per-pool DID-count quota reached. Delete an unused pool or keep a pool's DID set under 1,000.                                                                                                                                                                                                                                                                                                   |
