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

# Search Analytics API

> See what your team searches for across messages, contacts, calls, campaigns, and inbox — top queries, zero-result searches, click-through rates, and queries your search grammar could not parse.

# Search Analytics API

Every in-app search your team runs (the `?q=` filter on messages, contacts, calls, campaigns, and the inbox) is recorded per tenant. This resource reads those records back as four aggregations for an operator dashboard — the most-run queries, the queries that returned nothing, the click-through rate per query, and the queries your search syntax could not parse — plus one endpoint to attribute a click back to the search that produced it.

Use it to find the searches people rely on, the ones that come up empty (a sign of missing data or a confusing filter), and the inputs your search grammar rejects (a sign the help text or grammar needs work).

**Base path:** `/api/v1/search-analytics`

**Authentication:** API key (`X-API-Key`) or session JWT.

All data is scoped to the calling tenant — the schema is resolved server-side and is never read from the request, so cross-tenant reads and writes are not possible. Validation failures return `422` with a `VALIDATION_ERROR` body; see [Error codes](/api-reference/error-codes).

## Endpoints

| Method | Path                                      | Purpose                              |
| ------ | ----------------------------------------- | ------------------------------------ |
| `GET`  | `/api/v1/search-analytics/top-queries`    | Most-run searches over the window    |
| `GET`  | `/api/v1/search-analytics/zero-result`    | Searches that returned no results    |
| `GET`  | `/api/v1/search-analytics/click-through`  | Click-through rate per query         |
| `GET`  | `/api/v1/search-analytics/parse-failures` | Searches the grammar could not parse |
| `POST` | `/api/v1/search-analytics/click`          | Attribute a click to a search        |

## Shared range filters

The four `GET` aggregation endpoints take the same query parameters, so a dashboard can share one filter state across every panel.

| Parameter | Type                          | Default    | Notes                                                                          |
| --------- | ----------------------------- | ---------- | ------------------------------------------------------------------------------ |
| `from`    | ISO 8601 date/datetime string | 7 days ago | Accepts a date-input value like `2026-05-01` as well as a full timestamp.      |
| `to`      | ISO 8601 date/datetime string | now        | Capped at the current time.                                                    |
| `surface` | enum                          | `all`      | One of `all`, `messages`, `contacts`, `calls`, `campaigns`, `inbox`, `global`. |
| `limit`   | integer                       | `50`       | `1`–`200`. The panels render a top-N list.                                     |

The window is capped at 90 days. If `from` reaches further back than that, it is silently pinned to 90 days before `to`. Searches run while impersonating a tenant (support exploration) are excluded so they never skew a customer's own numbers.

Every aggregation responds with the standard envelope: the rows plus the resolved range.

```json Response shape theme={null}
{
  "data": {
    "rows": [ /* see each endpoint below */ ],
    "range": { "from": "2026-06-11T00:00:00.000Z", "to": "2026-06-18T12:00:00.000Z" }
  },
  "meta": {
    "request_id": "req_…",
    "timestamp": "2026-06-18T12:00:00.000Z"
  }
}
```

## Top queries

`GET /api/v1/search-analytics/top-queries` returns the most-run searches over the window, ordered by count. Only successfully-parsed searches are counted.

Each row:

| Field               | Type      | Description                                                         |
| ------------------- | --------- | ------------------------------------------------------------------- |
| `query_normalized`  | string    | The search normalized for grouping (with any personal data masked). |
| `count`             | integer   | Times this query was run.                                           |
| `surfaces`          | string\[] | Which surfaces it ran against.                                      |
| `avg_results`       | number    | Average result count, to one decimal.                               |
| `zero_result_count` | integer   | How many of those runs returned nothing.                            |
| `last_seen_at`      | string    | Timestamp of the most recent run.                                   |

## Zero-result queries

`GET /api/v1/search-analytics/zero-result` returns the searches that returned no results, ordered by count — the most actionable panel, since each one is a query your team expected to match something and it did not.

Each row:

| Field              | Type      | Description                           |
| ------------------ | --------- | ------------------------------------- |
| `query_normalized` | string    | The normalized, masked query.         |
| `count`            | integer   | Times it was run and came back empty. |
| `surfaces`         | string\[] | Which surfaces it ran against.        |
| `last_seen_at`     | string    | Timestamp of the most recent run.     |

## Click-through rate

`GET /api/v1/search-analytics/click-through` returns the click-through rate per query — the share of runs where someone opened a result from the list. Only searches that returned at least one result are included. Record clicks with `POST /click` (below) for this panel to be meaningful.

Each row:

| Field              | Type      | Description                                            |
| ------------------ | --------- | ------------------------------------------------------ |
| `query_normalized` | string    | The normalized, masked query.                          |
| `count`            | integer   | Total runs of this query.                              |
| `click_count`      | integer   | Runs where a result was clicked.                       |
| `ctr`              | number    | `click_count / count` as a percentage, to one decimal. |
| `surfaces`         | string\[] | Which surfaces it ran against.                         |
| `last_seen_at`     | string    | Timestamp of the most recent run.                      |

## Parse failures

`GET /api/v1/search-analytics/parse-failures` returns the searches that failed to parse against the search grammar, ordered by count. These point at inputs your team keeps typing that the search syntax rejects — useful for tuning the grammar or the help text.

Each row:

| Field               | Type      | Description                                                                                                              |
| ------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------ |
| `query_normalized`  | string    | The normalized, masked query.                                                                                            |
| `query_text_sample` | string    | A representative raw input for the group. Personal data (emails, phone numbers, tokens) is masked before it is returned. |
| `count`             | integer   | Times this query failed to parse.                                                                                        |
| `surfaces`          | string\[] | Which surfaces it ran against.                                                                                           |
| `last_seen_at`      | string    | Timestamp of the most recent run.                                                                                        |

## Record a click

`POST /api/v1/search-analytics/click` attributes a click to the search that produced the result list, feeding the click-through panel. Call it when a user opens a result.

| Field               | Type   | Description                                                          |
| ------------------- | ------ | -------------------------------------------------------------------- |
| `searchAnalyticsId` | string | The id of the search record the result came from.                    |
| `entityId`          | string | The id of the result that was opened (message, contact, call, etc.). |

```bash Record a click theme={null}
curl -X POST https://api.orbit.dev/api/v1/search-analytics/click \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "searchAnalyticsId": "sa_…", "entityId": "contact_…" }'
```

The call is idempotent and safe to fire-and-forget: re-clicking the same result just refreshes the click. The response reports whether a record was stamped.

```json Response theme={null}
{
  "data": { "recorded": true },
  "meta": { "request_id": "req_…", "timestamp": "2026-06-18T12:00:00.000Z" }
}
```

`recorded` is `false` when the `searchAnalyticsId` does not match a search in your tenant or it has aged out of retention. This is reported as a `200`, not a `404`, so a client can stay fire-and-forget and so the response never reveals whether an id exists in another tenant.

## See also

* [Analytics API](/api-reference/analytics)
* [Error codes](/api-reference/error-codes)
