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

# Worked Search Analytics samples

> Click-through and zero-result reads over a real from/to window with surfaced envelopes, plus the errors worth branching on.

## Worked Search Analytics samples

The four read endpoints on this page share one filter shape — a `from`/`to`
window (a calendar day `YYYY-MM-DD` or a full ISO 8601 timestamp), an
optional `surface` (`messages`, `contacts`, `calls`, `campaigns`, `inbox`,
`global`, or the default `all`), and a `limit` (default 50, max 200) — so
one filter state serves every panel. The reader chain: **pull the window →
read zero-result next to click-through → act on the queries that miss**.
The operator narrative behind the same data lives in [Read Search
Analytics](/guides/search-analytics-reading); this overlay stays
envelope-accurate — request, response, and the errors worth branching on.

The samples show cURL and TypeScript — the two most-requested languages; the
other four tabs appear on the generated operation blocks below. The Node
SDK's `client.request(method, path)` escape hatch takes the same route shape
for routes it does not cover with a typed method.

### 1. Read click-through per query

`GET /api/v1/search-analytics/click-through` ranks queries by impression
count over the window and returns each row's click count and derived CTR.
Two worked windows: a calendar-day pair (the shape a date picker emits) and
a full timestamp pair, narrowed to one surface.

<CodeGroup>
  ```bash cURL theme={null}
  curl -G "https://api.orbit.devotel.io/api/v1/search-analytics/click-through" \
    -H "X-API-Key: dv_live_sk_your_key_here" \
    --data-urlencode "from=2026-08-01" \
    --data-urlencode "to=2026-08-31" \
    --data-urlencode "surface=messages" \
    --data-urlencode "limit=10"
  ```

  ```typescript Node.js theme={null}
  const url = new URL(
    "https://api.orbit.devotel.io/api/v1/search-analytics/click-through",
  );
  url.searchParams.set("from", "2026-08-01");
  url.searchParams.set("to", "2026-08-31");
  url.searchParams.set("surface", "messages");
  url.searchParams.set("limit", "10");

  const res = await fetch(url, {
    headers: { "X-API-Key": process.env.ORBIT_API_KEY! },
  });
  const body = await res.json();
  // Rows arrive count-ordered; ctr is the row's click percentage (0–100).
  for (const row of body.data.rows) {
    console.log(row.query_normalized, row.count, row.click_count, row.ctr);
  }
  ```
</CodeGroup>

```json 200 theme={null}
{
  "data": {
    "rows": [
      {
        "query_normalized": "status:failed",
        "count": 412,
        "click_count": 218,
        "ctr": 52.9,
        "surfaces": ["messages"],
        "last_seen_at": "2026-08-30T14:12:03.000Z"
      },
      {
        "query_normalized": "invoice august",
        "count": 187,
        "click_count": 22,
        "ctr": 11.8,
        "surfaces": ["messages", "global"],
        "last_seen_at": "2026-08-29T09:41:17.000Z"
      }
    ],
    "range": {
      "from": "2026-08-01T00:00:00.000Z",
      "to": "2026-08-31T23:59:59.999Z"
    }
  },
  "meta": {
    "request_id": "req_4c2a91f0",
    "timestamp": "2026-08-31T12:00:00.000Z"
  }
}
```

A date-only `from` is anchored to the start of that UTC day and a date-only
`to` to the end of it, and the echoed `data.range` shows the resolved
bounds. `ctr` is the share of a query's searches that ended in a click, as a
percentage — read `count` next to it, because `ctr` is computed over the
queries that returned at least one result. A row like `invoice august` —
impressions accumulate, clicks stay low — reads as "the results render but
nobody picks one": the ranking or snippet signal to act on.

### 2. Read zero-result queries

`GET /api/v1/search-analytics/zero-result` lists the queries that returned
no hits over the same window shape. Every row is a search your team expected
to match something and did not — the panel tenant support teams work from to
close content and lexicon gaps.

<CodeGroup>
  ```bash cURL theme={null}
  curl -G "https://api.orbit.devotel.io/api/v1/search-analytics/zero-result" \
    -H "X-API-Key: dv_live_sk_your_key_here" \
    --data-urlencode "from=2026-08-01T00:00:00.000Z" \
    --data-urlencode "to=2026-08-31T23:59:59.999Z" \
    --data-urlencode "limit=10"
  ```

  ```typescript Node.js theme={null}
  const url = new URL(
    "https://api.orbit.devotel.io/api/v1/search-analytics/zero-result",
  );
  url.searchParams.set("from", "2026-08-01T00:00:00.000Z");
  url.searchParams.set("to", "2026-08-31T23:59:59.999Z");
  url.searchParams.set("limit", "10");

  const res = await fetch(url, {
    headers: { "X-API-Key": process.env.ORBIT_API_KEY! },
  });
  const body = await res.json();
  for (const row of body.data.rows) {
    console.log(row.query_normalized, row.count, row.surfaces);
  }
  ```
</CodeGroup>

```json 200 theme={null}
{
  "data": {
    "rows": [
      {
        "query_normalized": "rma return label",
        "count": 64,
        "surfaces": ["global", "messages"],
        "last_seen_at": "2026-08-30T11:02:44.000Z"
      },
      {
        "query_normalized": "refund policy fr",
        "count": 18,
        "surfaces": ["global"],
        "last_seen_at": "2026-08-27T16:20:09.000Z"
      }
    ],
    "range": {
      "from": "2026-08-01T00:00:00.000Z",
      "to": "2026-08-31T23:59:59.999Z"
    }
  },
  "meta": {
    "request_id": "req_4c2a91f1",
    "timestamp": "2026-08-31T12:00:01.000Z"
  }
}
```

Cross-read a zero-result row against the click-through panel for the same
window: a query with a rising zero-result `count` and no matching
click-through row is a vocabulary gap (the grammar or lexicon does not
recognize the terms) — chase it once more through
`GET /api/v1/search-analytics/parse-failures`, which carries the same range
filter plus a masked `query_text_sample` of the raw input. An empty `rows`
array with the resolved `range` is a normal `200`, not an error: nothing
missed in that window.

### 3. Errors worth branching on

Query params are validated before any aggregation runs, so the branches are
small and terminal.

```json 422 theme={null}
{
  "error": {
    "code": "VALIDATION_ERROR",
    "status": 422,
    "message": "Invalid query parameters",
    "details": {
      "issues": [
        {
          "field": "from",
          "message": "expected a calendar date (YYYY-MM-DD) or an ISO 8601 timestamp"
        }
      ]
    }
  },
  "meta": {
    "request_id": "req_4c2a91f2",
    "timestamp": "2026-08-31T12:00:02.000Z"
  }
}
```

| Class                      | Trigger                                                                                                                                            | Branch response                                                                                            |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| **422 `VALIDATION_ERROR`** | `from`/`to` that is neither a real calendar day (`2026-02-30` rolls over) nor a parseable timestamp; `surface` outside the enum; `limit` above 200 | **Fix, then resend.** `error.details.issues` names the rejected field; correct the bound and re-send once. |
| **401 `UNAUTHORIZED`**     | Missing or invalid API key                                                                                                                         | **Surface.** Re-check the key; do not retry the same credentials.                                          |
| **403 `FORBIDDEN`**        | Key without an accepted org role                                                                                                                   | **Surface.** The role must change before the read succeeds.                                                |
| **429 `RATE_LIMITED`**     | Panel polling faster than the allowance                                                                                                            | **Retry after `error.details.retry_after`.** Back off, then re-send the same window.                       |

All four are terminal-or-backoff, never blind-retry: the `422` and role
failures will not succeed until the request or the role changes, and the
`429` carries its own wait. The envelope field semantics and the global
retry-vs-terminal decision table live in the [error-handling
guide](/guides/error-handling-examples).
