> For the complete documentation index, see [llms.txt](https://docs.vectra.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.vectra.ai/operations/investigate/investigate-api-user-guide.md).

# Investigate API user guide

## Investigate API user guide

{% hint style="info" %}
**Please Note:**

This only applies to the new Vectra AI experience (RUX).
{% endhint %}

{% hint style="warning" %}
**Investigate API is currently in PREVIEW. Please provide feedback to your respective account teams.**
{% endhint %}

{% hint style="info" %}
**Subscription required:** The Investigate API requires a **Vectra Pro subscription**. API clients on tenants without a Vectra Pro subscription will not have access to these endpoints.
{% endhint %}

### Overview

The Vectra AI Investigations API (also called the Metadata API) enables you to run SQL-like queries directly against network metadata and cloud logs captured by your Vectra sensors. This is the same data that powers Vectra's built-in detections, giving you full access to underlying evidence for threat hunting, incident response, and ad-hoc investigation.

**Key capabilities:**

* Query raw network session data (connections, DNS, HTTP, TLS, SMB, Kerberos, LDAP, RDP, SSH, beacons, and more) back through **your tenant's data retention period** — lookback is governed by your Vectra deployment's configured retention (commonly 14 days), not a fixed API limit
* Query cloud audit logs: **AWS CloudTrail**, **Azure ARM**, **Microsoft 365** (Entra ID, SharePoint, Exchange, Teams), and **Entra ID sign-ins**
* Filter by host entity IDs, IP addresses, timestamps, protocol fields, MITRE technique indicators, and any field documented in the schema reference
* Retrieve up to **10,000 rows** per query with full pagination support
* Use aggregation functions (COUNT, SUM, AVG, etc.), `GROUP BY`/`HAVING`, plain `UNION` and `UNION ALL`, subqueries, and `CASE WHEN` for statistical analysis and result shaping

The API is asynchronous: you **submit** a query, receive a `request_id`, then **poll** for completion and **retrieve** paginated results.

### Prerequisites & Authentication

**What You Need**

<table><thead><tr><th width="225.45703125">Item</th><th>Details</th></tr></thead><tbody><tr><td><strong>Vectra Brain URL</strong></td><td>Your Vectra deployment URL, e.g., <code>https://your-brain.region.portal.vectra.ai</code></td></tr><tr><td><strong>API Client Credentials</strong></td><td>OAuth 2.0 Client ID and Client Secret (created in Vectra Admin → API Clients)</td></tr><tr><td><strong>Access Token</strong></td><td>Bearer token obtained via the OAuth token endpoint</td></tr><tr><td><strong>Postman</strong> (optional)</td><td>Version 9+ recommended; import the collection or build requests manually</td></tr></tbody></table>

**Obtaining an Access Token**

The Investigations API uses OAuth 2.0 Bearer Token authentication. Obtain a token before making API calls.

**Token endpoint:**

```
POST https://{your-brain}/oauth2/token
```

**Postman setup:**

1. Create a new request → set method to `POST`
2. URL: `https://{your-brain}/oauth2/token`
3. Body → select `x-www-form-urlencoded` and add:

<table><thead><tr><th width="279.91796875">Key</th><th>Value</th></tr></thead><tbody><tr><td><code>grant_type</code></td><td><code>client_credentials</code></td></tr><tr><td><code>client_id</code></td><td><code>{your_client_id}</code></td></tr><tr><td><code>client_secret</code></td><td><code>{your_client_secret}</code></td></tr></tbody></table>

4. Send the request. Copy the `access_token` value from the response.

**Token response example:**

json

```json
{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 3600
}
```

**Using the token in subsequent requests:**

In Postman, go to the **Authorization** tab → select **Bearer Token** → paste the access token. Alternatively, add a header manually:

```
Authorization: Bearer {access_token}
```

> **Tip:** In Postman, store the token as a collection variable (`{{access_token}}`) and reference it with `Bearer {{access_token}}`. You can automate token refresh using a pre-request script.

### How It Works — Submit, Poll, Retrieve

The Investigations API uses an **asynchronous** pattern. Queries may take several seconds or longer depending on data volume and time range.

```
Step 1: Submit Query
  POST /api/v3.4/investigations/
  Body: { "query": "SELECT ...", "version": "1.0" }
  → Response: { "request_id": "abc-123", "searchable_range": {...} }

Step 2: Poll for Completion
  GET /api/v3.4/investigations/abc-123/
  → Response: { "meta": { "query_status": "RUNNING", "data": [] } }   ← keep polling
  → Response: { "meta": { "query_status": "SUCCESS" }, "data": [...] }  ← done

Step 3: Retrieve Additional Pages (if needed)
  GET /api/v3.4/investigations/abc-123/?page=2&page_size=50
  → Response: { "status": "completed", "results": [...], "next_page": 3 }
```

**Status values:**

| `meta.query_status` | Meaning                                                                                |
| ------------------- | -------------------------------------------------------------------------------------- |
| `RUNNING`           | Query is actively executing — keep polling                                             |
| `SUCCESS`           | Query finished — `data` array contains your results                                    |
| `FAILED`            | Query failed — check the response body for error details (exact structure unconfirmed) |

**Recommended polling interval:** 1–3 seconds. Most queries complete within 5–15 seconds.

> **Postman Automation Tip:** Add this snippet to the **Tests** tab of your POST request to automatically capture the `request_id` into a collection variable:
>
> javascript
>
> ```javascript
> var json = pm.response.json();
> pm.collectionVariables.set("request_id", json.request_id);
> ```
>
> Your GET request can then reference `{{request_id}}` in the URL path automatically — no manual copy/paste needed. See Appendix B for the full script.

### API Reference

**POST /api/v3.4/investigations/ — Submit a Query**

**Full URL:** `https://{your-brain}/api/v3.4/investigations/`

**Method:** `POST`

**Headers:**

<table><thead><tr><th width="236.00390625">Header</th><th>Value</th></tr></thead><tbody><tr><td><code>Authorization</code></td><td><code>Bearer {access_token}</code></td></tr><tr><td><code>Content-Type</code></td><td><code>application/json</code></td></tr></tbody></table>

**Request body (JSON):**

<table><thead><tr><th width="114.44140625">Field</th><th width="103.63671875">Type</th><th width="113.3046875">Required</th><th>Description</th></tr></thead><tbody><tr><td><code>query</code></td><td>string</td><td>Yes</td><td>SQL-like query string. See Query Language Reference.</td></tr><tr><td><code>version</code></td><td>string</td><td>No</td><td>Query language version. Use <code>"1.0"</code> (default).</td></tr></tbody></table>

**Example request body:**

json

```json
{
  "query": "SELECT timestamp, id.orig_h, id.resp_h, id.resp_p FROM network.isession WHERE timestamp > date_add('hour', -24, now()) AND local_orig = true AND local_resp = false LIMIT 100",
  "version": "1.0"
}
```

**Success response — 200 OK:**

json

```json
{
  "request_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "searchable_range": {
    "searchable_days_allowed": 14
  }
}
```

**Error responses:**

<table><thead><tr><th width="234.23046875">HTTP Status</th><th>Cause</th></tr></thead><tbody><tr><td><code>400 Bad Request</code></td><td>Invalid query syntax or unsupported clause</td></tr><tr><td><code>401 Unauthorized</code></td><td>Missing or expired Bearer token</td></tr><tr><td><code>403 Forbidden</code></td><td>Token does not have Investigations API permission, or tenant lacks a Vectra Pro subscription</td></tr><tr><td><code>413 Payload Too Large</code></td><td>Response exceeds the maximum allowed size — narrow the query (tighter <code>WHERE</code>, smaller <code>LIMIT</code>)</td></tr><tr><td><code>429 Too Many Requests</code></td><td>Rate limit exceeded (5 POST requests/minute)</td></tr></tbody></table>

The API surfaces two distinct error patterns depending on when the error is detected.

**Syntax errors — returned directly in the POST response** (query fails to parse before execution):

json

```json
{
  "error": {
    "errorCode": "SYNTAX_ERROR",
    "errorId": "SAA23Q",
    "extra": [
      {
        "line": 1,
        "column": 96,
        "offending_symbol": "WHERE",
        "message": "query parsing failed: mismatched input 'WHERE' expecting '.'"
      }
    ]
  }
}
```

<table><thead><tr><th width="295.515625">Error field</th><th>Description</th></tr></thead><tbody><tr><td><code>error.errorCode</code></td><td><code>SYNTAX_ERROR</code> for parse failures</td></tr><tr><td><code>error.errorId</code></td><td>Unique ID for this error — include when reporting issues</td></tr><tr><td><code>error.extra[].line</code></td><td>Line number in the query where the error occurred</td></tr><tr><td><code>error.extra[].column</code></td><td>Character position of the error</td></tr><tr><td><code>error.extra[].offending_symbol</code></td><td>The token that triggered the parse failure</td></tr><tr><td><code>error.extra[].message</code></td><td>Human-readable description of the parse error</td></tr></tbody></table>

> **Note:** The `._all` suffix on table names (e.g. `network.isession._all`) is optional — bare table names (`network.isession`), which this guide uses in its examples, are also accepted.

**Runtime errors — returned in the GET response** (query parses successfully but fails during execution):

json

```json
{
  "error": {
    "errorCode": "DATABASE_ERROR",
    "errorId": "OWHVWH",
    "extra": [
      {
        "column": "orig_bytes",
        "error_name": "COLUMN_NOT_FOUND",
        "error_type": "USER_ERROR"
      }
    ]
  }
}
```

<table><thead><tr><th width="262.29296875">Error field</th><th>Description</th></tr></thead><tbody><tr><td><code>error.errorCode</code></td><td><code>DATABASE_ERROR</code> for runtime failures</td></tr><tr><td><code>error.errorId</code></td><td>Unique ID for this error — include when reporting issues</td></tr><tr><td><code>error.extra[].column</code></td><td>The field name that caused the error</td></tr><tr><td><code>error.extra[].error_name</code></td><td>Specific error type (e.g. <code>COLUMN_NOT_FOUND</code>)</td></tr><tr><td><code>error.extra[].error_type</code></td><td><code>USER_ERROR</code> indicates the query itself is the problem</td></tr></tbody></table>

Common `error_name` values for runtime errors:

<table data-header-hidden><thead><tr><th width="180.4765625"></th><th width="174.38671875"></th><th></th></tr></thead><tbody><tr><td><code>error_name</code></td><td><code>column</code> present</td><td>Meaning</td></tr><tr><td><code>COLUMN_NOT_FOUND</code></td><td>Yes — names the offending field</td><td>A field name in SELECT or WHERE does not exist in the table — check the schema reference for correct field names</td></tr><tr><td><code>TYPE_MISMATCH</code></td><td>No</td><td>A value, cast, or function argument has an incompatible type — check comparisons (e.g. string field vs. numeric literal), <code>CAST</code> expressions, and aggregate function inputs</td></tr></tbody></table>

**GET /api/v3.4/investigations/{request\_id}/ — Get Results**

**Full URL:** `https://{your-brain}/api/v3.4/investigations/{request_id}/`

**Method:** `GET`

**Headers:**

<table><thead><tr><th width="266.78125">Header</th><th>Value</th></tr></thead><tbody><tr><td><code>Authorization</code></td><td><code>Bearer {access_token}</code></td></tr></tbody></table>

**Query parameters:**

<table><thead><tr><th width="129.83203125">Parameter</th><th width="110.453125">Type</th><th width="112.35546875">Default</th><th>Description</th></tr></thead><tbody><tr><td><code>page</code></td><td>integer</td><td>1</td><td>Page number (1-indexed)</td></tr><tr><td><code>page_size</code></td><td>integer</td><td>100</td><td>Rows per page (max 10,000)</td></tr></tbody></table>

**Success response — 200 OK (completed):**

json

```json
{
  "request_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "data": [
    {
      "timestamp": "2026-03-10T14:23:01.000Z",
      "orig_h": "10.0.1.45",
      "resp_h": "93.184.216.34",
      "resp_p": 443
    }
  ],
  "meta": {
    "page": 1,
    "page_size": 50,
    "num_rows_available": 342,
    "estimated_file_size_bytes": 18400,
    "query_status": "SUCCESS",
    "columns": [
      ["timestamp", [{"type": "string"}, ""]],
      ["orig_h",    [{"type": "string"}, ""]],
      ["resp_h",    [{"type": "string"}, ""]],
      ["resp_p",    [{"type": "number"}, ""]]
    ]
  }
}
```

**Response fields:**

<table><thead><tr><th width="293.2421875">Field</th><th width="106.52734375">Location</th><th>Description</th></tr></thead><tbody><tr><td><code>request_id</code></td><td>top level</td><td>Unique ID for this query job</td></tr><tr><td><code>data</code></td><td>top level</td><td>Array of result rows — your query results are here</td></tr><tr><td><code>meta.query_status</code></td><td><code>meta</code></td><td><code>"RUNNING"</code> while executing, <code>"SUCCESS"</code> when complete</td></tr><tr><td><code>meta.num_rows_available</code></td><td><code>meta</code></td><td>Total rows returned by the query</td></tr><tr><td><code>meta.page</code></td><td><code>meta</code></td><td>Current page number</td></tr><tr><td><code>meta.page_size</code></td><td><code>meta</code></td><td>Rows returned on this page</td></tr><tr><td><code>meta.estimated_file_size_bytes</code></td><td><code>meta</code></td><td>Estimated size of the full result set in bytes</td></tr><tr><td><code>meta.columns</code></td><td><code>meta</code></td><td>Array of <code>[column_name, [{type}, hint]]</code> tuples describing the result schema</td></tr></tbody></table>

> **Checking for more pages:** Compare `meta.num_rows_available` against `meta.page_size`. If `num_rows_available > page_size`, there are additional pages to retrieve. Increment the `page` parameter until you have collected all rows.

**Still running — 202 OK:**

json

```json
{
  "data": [],
  "request_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "meta": {
    "page": 1,
    "page_size": 50,
    "estimated_file_size_bytes": null,
    "num_rows_available": 0,
    "query_status": "RUNNING",
    "columns": []
  }
}
```

> **Polling tip:** The response structure is identical whether the query is running or complete — `data` and `columns` are simply empty arrays while the query is in progress. Poll until `meta.query_status` equals `"SUCCESS"` before reading `data`.

**Failed query response (expected structure):**

json

```json
{
  "request_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "meta": {
    "query_status": "FAILED",
    "error": "Syntax error near 'GROUPBY' at position 42"
  }
}
```

### Query Language Reference

**Table Naming Convention**

The `._all` suffix on table names is **optional**. Both forms are valid and return identical results:

sql

```sql
-- Bare table name (used throughout this guide)
FROM network.isession
FROM network.dns
FROM aws.cloudtrail

-- Also valid — suffix form
FROM network.isession._all
FROM network.dns._all
FROM aws.cloudtrail._all
```

> **Note:** Older queries or scripts written with the `._all` suffix will continue to work — the suffix is accepted but no longer required.

**Supported Clauses**

<table><thead><tr><th width="245.640625">Clause</th><th width="116.46875">Supported</th><th>Notes</th></tr></thead><tbody><tr><td><code>SELECT</code></td><td>✅</td><td>Use <code>*</code> or specific fields</td></tr><tr><td><code>FROM</code></td><td>✅</td><td>One table per query (no JOIN)</td></tr><tr><td><code>WHERE</code></td><td>✅</td><td>Standard filters + functions</td></tr><tr><td><code>ORDER BY</code></td><td>✅</td><td><code>ASC</code> or <code>DESC</code></td></tr><tr><td><code>LIMIT</code></td><td>✅</td><td>Recommended; maximum 10,000</td></tr><tr><td><code>GROUP BY</code></td><td>✅</td><td>For aggregation queries</td></tr><tr><td><code>HAVING</code></td><td>✅</td><td>Filter on aggregate results</td></tr><tr><td><code>UNION ALL</code></td><td>✅</td><td>Combine results from multiple queries, keeping duplicates</td></tr><tr><td>Plain <code>UNION</code></td><td>✅</td><td>Combine and de-duplicate results from multiple queries</td></tr><tr><td>Subqueries</td><td>✅</td><td>Supported, e.g. <code>WHERE x IN (SELECT ...)</code></td></tr><tr><td><code>CASE WHEN</code></td><td>✅</td><td>Supported for conditional column values</td></tr><tr><td><code>JOIN</code></td><td>❌</td><td>Not supported — use <code>UNION</code>/<code>UNION ALL</code> or subqueries instead</td></tr><tr><td><code>INSERT</code> / <code>UPDATE</code> / <code>DELETE</code></td><td>❌</td><td>Read-only API</td></tr></tbody></table>

**Field Notation Rules**

The rule differs by clause:

* **`WHERE`** — struct fields **must** use the full dot-notation path (e.g. `id.resp_h`). A flat name (`resp_h`) doesn't exist as a top-level field, so it fails with a `COLUMN_NOT_FOUND` / `DATABASE_ERROR` (HTTP 400) — the same runtime error you'd get for any misspelled or nonexistent field, not a distinct syntax restriction.
* **`SELECT`** — either form works. `SELECT id.orig_h` returns the column as `orig_h` in results (the dot-notation path is flattened to the sub-field name as the output column name).
* **`ORDER BY`** — either form works, but a flat name only resolves if that column is present in the `SELECT` list (it's matching the *projected output column name*, not the raw struct path). `ORDER BY resp_h` succeeds if `id.resp_h` (or `resp_h`) is selected; it fails with `COLUMN_NOT_FOUND` if `resp_h` isn't part of the output at all.

sql

```sql
-- Correct
SELECT id.orig_h, id.resp_h FROM network.http WHERE id.resp_h = '1.2.3.4' ORDER BY id.resp_p ASC
SELECT id.orig_h, id.resp_h FROM network.http WHERE orig_hostname.id = 7832 ORDER BY resp_h ASC   -- ORDER BY resp_h works because resp_h is in the SELECT list

-- Incorrect — COLUMN_NOT_FOUND (DATABASE_ERROR, HTTP 400)
SELECT id.orig_h FROM network.http WHERE resp_h = '1.2.3.4'                    -- resp_h not a real field in WHERE
SELECT id.orig_h FROM network.http WHERE id.resp_p = 443 ORDER BY resp_h       -- resp_h isn't selected, so ORDER BY can't resolve it
```

**Supported Functions**

**Aggregate functions:** `COUNT`, `COUNT(DISTINCT ...)`, `MAX`, `MIN`, `SUM`, `AVG`, `STDDEV`, `STDDEV_SAMP`, `STDDEV_POP`, `APPROX_DISTINCT`, `APPROX_PERCENTILE`

**String functions:** `LOWER`, `UPPER`, `LENGTH`, `ABS`, `CONCAT`, `CONTAINS`, `COALESCE`, `TRIM`, `SUBSTR`, `REPLACE`, `REVERSE`, `SPLIT`, `SPLIT_PART`, `STRPOS`, `NULLIF`

**Time functions:** `DATE`, `NOW`, `DATE_ADD`, `DATE_DIFF`, `DATE_TRUNC`, `FROM_ISO8601_TIMESTAMP`, `FROM_UNIXTIME`, `TO_UNIXTIME`

**Regex functions:** `REGEXP_COUNT`, `REGEXP_EXTRACT_ALL`, `REGEXP_EXTRACT`, `REGEXP_LIKE`, `REGEXP_POSITION`, `REGEXP_REPLACE`, `REGEXP_SPLIT`

**JSON functions:** `JSON_PARSE`, `JSON_ARRAY_LENGTH`, `JSON_ARRAY_CONTAINS`. (`JSON_EXTRACT`, `JSON_EXTRACT_SCALAR`, `JSON_FORMAT`, `JSON_SIZE` are expected to work but haven't been independently confirmed — verify before relying on these in production.)

**Type casting:** `TRY_CAST`, `CAST`

**Conditional expressions:** `CASE WHEN ... THEN ... ELSE ... END`

**Array / predicate functions:** `ANY_MATCH`, `ALL_MATCH`, `DISTINCT`, `ARRAY_AGG` (supports `ARRAY_AGG(DISTINCT ...)`), `CARDINALITY`

**Common Time Filter Pattern**

sql

```sql
-- Last 24 hours
WHERE timestamp > date_add('hour', -24, now())

-- Last 7 days
WHERE timestamp > date_add('day', -7, now())

-- Specific time window
WHERE timestamp >= '2026-03-10T00:00:00Z'
  AND timestamp <= '2026-03-11T00:00:00Z'
```

**Common Fields (All Network Tables)**

<table><thead><tr><th width="205.4140625">Field</th><th width="147.29296875">Type</th><th>Description</th></tr></thead><tbody><tr><td><code>timestamp</code></td><td>timestamp</td><td>Record timestamp — always use this (not <code>ts</code>)</td></tr><tr><td><code>id.orig_h</code></td><td>string</td><td>Source (originator) IP address</td></tr><tr><td><code>id.orig_p</code></td><td>integer</td><td>Source port</td></tr><tr><td><code>id.resp_h</code></td><td>string</td><td>Destination (responder) IP address</td></tr><tr><td><code>id.resp_p</code></td><td>integer</td><td>Destination port</td></tr><tr><td><code>local_orig</code></td><td>boolean</td><td><code>true</code> if source IP is internal</td></tr><tr><td><code>local_resp</code></td><td>boolean</td><td><code>true</code> if destination IP is internal</td></tr><tr><td><code>orig_hostname.id</code></td><td>integer</td><td>Vectra host entity ID for source host</td></tr><tr><td><code>orig_hostname.name</code></td><td>string</td><td>Display name of source host</td></tr><tr><td><code>uid</code></td><td>string</td><td>Unique session ID</td></tr></tbody></table>

> For complete field listings by table, see the Table Schema Reference.

### Sample Queries

All samples below are formatted as complete Postman request bodies (JSON). Submit each as the body of `POST /api/v3.4/investigations/`.

**Basic Queries**

**1. Recent Outbound Connections (Last 24 Hours)**

Returns outbound sessions from internal hosts to external destinations.

json

```json
{
  "query": "SELECT timestamp, id.orig_h, id.resp_h, id.resp_p, orig_ip_bytes, resp_ip_bytes FROM network.isession WHERE timestamp > date_add('hour', -24, now()) AND local_orig = true AND local_resp = false ORDER BY timestamp DESC LIMIT 100",
  "version": "1.0"
}
```

**2. DNS Queries from a Specific Host**

Returns all DNS lookups made by a host identified by its IP address.

json

```json
{ 
  "query": "SELECT timestamp, uid, id.orig_h, orig_hostname, id.resp_h, id.resp_p, qtype_name, query, answers, total_answers, rejected, sensor_uid FROM network.dns WHERE id.orig_h = '1.2.3.4' AND timestamp > date_add('hour', -24, now()) ORDER BY timestamp DESC LIMIT 100", 
  "version": "1.0" }
```

> Replace `1.2.3.4` with the actual Vectra host IP.

**3. HTTP Activity to a Suspicious Domain**

Finds all HTTP requests to a specific host header value.

json

```json
{ 
  "query": "SELECT timestamp, id.orig_h, host, uri, method, status_code, user_agent FROM network.http WHERE timestamp > date_add('day', -3, now()) AND host = 'suspicious-domain.com' ORDER BY timestamp DESC LIMIT 100",
  "version": "1.0" }
```

**4. Failed Kerberos Authentication Attempts**

Returns failed Kerberos events which may indicate brute force or password spraying.

json

```json
{
  "query": "SELECT timestamp, id.orig_h, client, service, error_msg FROM network.kerberos WHERE timestamp > date_add('hour', -6, now()) AND success = false ORDER BY timestamp DESC LIMIT 500",
  "version": "1.0"
}
```

**5. Large Outbound Data Transfers**

Identifies sessions where an internal host sent more than 10 MB to an external destination.

json

```json
{
  "query": "SELECT id.orig_h, id.resp_h, id.resp_p, COUNT(*) AS sessions, SUM(orig_ip_bytes) AS bytes_sent, SUM(resp_ip_bytes) AS bytes_received FROM network.isession WHERE timestamp > date_add('hour', -24, now()) AND local_orig = true AND local_resp = false GROUP BY id.orig_h, id.resp_h, id.resp_p HAVING SUM(orig_ip_bytes) > 10000000 ORDER BY bytes_sent DESC LIMIT 100",
  "version": "1.0"
}
```

**Advanced Queries**

**6. Top External Destinations by Connection Count**

Aggregates outbound connections grouped by destination IP, ordered by volume. Useful for identifying beaconing or bulk data transfer patterns.

json

```json
{
  "query": "SELECT id.resp_h, COUNT(*) AS connection_count, SUM(orig_ip_bytes) AS total_bytes_sent FROM network.isession WHERE timestamp > date_add('day', -1, now()) AND local_orig = true AND local_resp = false GROUP BY id.resp_h ORDER BY connection_count DESC LIMIT 50",
  "version": "1.0"
}
```

**7. DNS Tunneling Indicators**

Flags suspiciously long DNS query names (potential data exfiltration via DNS tunneling) combined with TXT record lookups.

json

```json
{
  "query": "SELECT timestamp, id.orig_h, orig_hostname.name, query, qtype_name, LENGTH(query) AS query_length FROM network.dns WHERE timestamp > date_add('hour', -24, now()) AND (LENGTH(query) > 50 OR qtype_name = 'TXT') ORDER BY query_length DESC LIMIT 200",
  "version": "1.0"
}
```

**8. Lateral Movement — Internal RDP Sessions**

Finds RDP connections between internal hosts, a primary lateral movement indicator.

json

```json
{
  "query": "SELECT timestamp, id.orig_h, id.resp_h, orig_hostname.name AS src_host, resp_hostname.name AS dst_host, client_name FROM network.rdp WHERE timestamp > date_add('hour', -24, now()) AND local_orig = true AND local_resp = true ORDER BY timestamp DESC LIMIT 200",
  "version": "1.0"
}
```

**9. AWS IAM Changes in the Last 7 Days**

Identifies mutating IAM API calls (user/role/policy changes) in AWS CloudTrail — a key persistence and privilege escalation indicator.

json

```json
{
  "query": "SELECT timestamp, event_name, user_identity.arn, source_ip_address, error_code FROM aws.cloudtrail WHERE timestamp > date_add('day', -7, now()) AND event_source = 'iam.amazonaws.com' AND read_only = 'false' ORDER BY timestamp DESC LIMIT 200",
  "version": "1.0"
}
```

**10. Entra ID Sign-ins from High-Risk Countries (UNION ALL)**

Combines sign-in events from multiple high-risk geographic locations using `UNION ALL`.

json

```json
{
  "query": "SELECT timestamp, user_principal_name, ip_address, location.country_or_region AS country, app_display_name, status.error_code FROM entra.signins WHERE timestamp > date_add('day', -7, now()) AND location.country_or_region = 'RU' UNION ALL SELECT timestamp, user_principal_name, ip_address, location.country_or_region AS country, app_display_name, status.error_code FROM entra.signins WHERE timestamp > date_add('day', -7, now()) AND location.country_or_region = 'KP' ORDER BY timestamp DESC LIMIT 500",
  "version": "1.0"
}
```

**Additional Query Patterns**

**11. Deduplicated Hosts Seen Across Two Protocols (Plain UNION)**

Returns the deduplicated set of source IPs seen as either an HTTP or a DNS client in the last 24 hours. Unlike `UNION ALL`, a host appearing in both tables is listed only once.

json

```json
{
  "query": "SELECT id.orig_h AS ip FROM network.http WHERE timestamp BETWEEN date_add('day', -1, now()) AND now() UNION SELECT id.orig_h AS ip FROM network.dns WHERE timestamp BETWEEN date_add('day', -1, now()) AND now() ORDER BY ip LIMIT 100",
  "version": "1.0"
}
```

**12. HTTP Status Code Categorization (CASE WHEN + GROUP BY)**

Buckets HTTP status codes into success/redirect/client\_error/server\_error categories and counts requests in each. Note that the `CASE WHEN` expression must be repeated in `GROUP BY` — column aliases can't be reused there.

json

```json
{
  "query": "SELECT status_code, CASE WHEN status_code < 300 THEN 'success' WHEN status_code < 400 THEN 'redirect' WHEN status_code < 500 THEN 'client_error' ELSE 'server_error' END AS status_category, COUNT(*) AS request_count FROM network.http WHERE timestamp BETWEEN date_add('day', -1, now()) AND now() GROUP BY status_code, CASE WHEN status_code < 300 THEN 'success' WHEN status_code < 400 THEN 'redirect' WHEN status_code < 500 THEN 'client_error' ELSE 'server_error' END ORDER BY request_count DESC LIMIT 20",
  "version": "1.0"
}
```

**13. High-Volume Source IPs on a Network Table (GROUP BY + HAVING)**

Flags source IPs making an unusually high number of HTTP requests in the last hour — useful for beaconing or scanning triage. `GROUP BY`/`HAVING` work the same way on any network table, not just `network.isession`.

json

```json
{
  "query": "SELECT id.orig_h AS ip, COUNT(*) AS request_count FROM network.http WHERE timestamp BETWEEN date_add('hour', -1, now()) AND now() GROUP BY id.orig_h HAVING COUNT(*) > 20 ORDER BY request_count DESC LIMIT 20",
  "version": "1.0"
}
```

### Pagination — Retrieving Large Result Sets

When a query matches more rows than your `page_size`, results are split across multiple pages. Use the `next_page` field in the response to retrieve subsequent pages.

**Step-by-Step Pagination in Postman**

**Step 1 — Submit the query** (POST):

json

```json
{
  "query": "SELECT timestamp, id.orig_h, id.resp_h FROM network.isession WHERE timestamp > date_add('day', -1, now()) AND local_orig = true LIMIT 10000",
  "version": "1.0"
}
```

**Step 2 — Poll for completion** (GET):

```
GET /api/v3.4/investigations/{request_id}/
```

**Step 3 — Retrieve page 1** (GET):

```
GET /api/v3.4/investigations/{request_id}/?page=1&page_size=500
```

**Step 4 — Continue if `next_page` is not null:**

```
GET /api/v3.4/investigations/{request_id}/?page=2&page_size=500
GET /api/v3.4/investigations/{request_id}/?page=3&page_size=500
...
```

**Stop when you've collected all rows** — compare `meta.num_rows_available` (total rows) against `meta.page * meta.page_size`. When the rows you've collected equals `meta.num_rows_available`, you're done.

> **Performance tip:** Use a `page_size` of 500–1,000 for large result sets to balance payload size and number of round trips. Your results will be in the `data` array of each response.

### Troubleshooting

<table><thead><tr><th width="239.3046875">Symptom</th><th width="243.8515625">Likely Cause</th><th>Resolution</th></tr></thead><tbody><tr><td><code>SYNTAX_ERROR</code> in POST response</td><td>Malformed SQL — bad clause order, unmatched quotes, unsupported keyword (e.g. <code>JOIN</code>)</td><td>Check <code>extra[].offending_symbol</code> and <code>extra[].message</code> for the exact location</td></tr><tr><td><code>COLUMN_NOT_FOUND</code> in GET response</td><td>Field name doesn't exist in the table</td><td>Check the schema reference for correct field names — e.g. <code>orig_ip_bytes</code> not <code>orig_bytes</code></td></tr><tr><td><code>COLUMN_NOT_FOUND</code> in GET response</td><td>Flat field name used in WHERE clause</td><td>Change <code>WHERE resp_h = '...'</code> to <code>WHERE id.resp_h = '...'</code></td></tr><tr><td><code>TYPE_MISMATCH</code> in GET response</td><td>Incompatible types in a comparison, cast, or function</td><td>Check that field types match their usage — e.g. wrap array fields with <code>CAST(... AS VARCHAR)</code> before using <code>REGEXP_LIKE</code>, avoid comparing string fields to numeric literals</td></tr><tr><td><code>EXPRESSION_NOT_AGGREGATE</code> in GET response</td><td>Mixing aggregate functions (<code>COUNT</code>, <code>SUM</code>, etc.) with non-aggregated columns/expressions in the same <code>SELECT</code> without <code>GROUP BY</code></td><td>Add a <code>GROUP BY</code> covering every non-aggregated column/expression, or remove the non-aggregated expressions</td></tr><tr><td><code>400 Bad Request</code></td><td>Unsupported clause (<code>JOIN</code>)</td><td>Remove JOIN; use <code>UNION</code>/<code>UNION ALL</code> or a subquery to combine results from multiple queries</td></tr><tr><td><code>401 Unauthorized</code></td><td>Missing or expired token</td><td>Re-authenticate via <code>POST /oauth2/token</code> and update the Bearer token</td></tr><tr><td><code>403 Forbidden</code></td><td>Insufficient API client permissions, or tenant lacks a Vectra Pro subscription</td><td>Ensure the API client has Investigations API access in Vectra Admin → API Clients, and that the tenant is on a Vectra Pro subscription</td></tr><tr><td><code>413 Payload Too Large</code></td><td>Result set too large to return</td><td>Narrow the <code>WHERE</code> clause or reduce <code>LIMIT</code>/time range, or paginate with a smaller <code>page_size</code></td></tr><tr><td><code>429 Too Many Requests</code></td><td>Rate limit exceeded</td><td>Wait 60 seconds before retrying; the limit is 5 POST requests/minute</td></tr><tr><td><code>error</code> object present in GET response</td><td>Runtime error (e.g. bad field name)</td><td>Check <code>error.extra[].error_name</code> and <code>error.extra[].column</code> for the specific cause</td></tr><tr><td>Query returns 0 results</td><td>Time range too narrow, wrong table, or requested range beyond your tenant's retention</td><td>Widen the time range (up to your tenant's retention period); verify the correct table name in the schema reference</td></tr><tr><td>Query times out</td><td>Query spans too much data</td><td>Add a more specific <code>WHERE</code> clause to narrow the result set; reduce time range</td></tr><tr><td>Struct field returns <code>null</code></td><td>External IP has no Vectra host record</td><td><code>orig_hostname</code> / <code>resp_hostname</code> fields are <code>null</code> for external IPs — this is expected</td></tr><tr><td><code>UNION</code>/<code>UNION ALL</code> returns unexpected column count</td><td>Column alias mismatch between branches</td><td>Ensure both SELECT branches have identical column count and matching aliases</td></tr></tbody></table>

### Rate Limits & Constraints

<table><thead><tr><th width="320.1875">Constraint</th><th>Value</th></tr></thead><tbody><tr><td><strong>POST requests (submit query)</strong></td><td>5 per minute</td></tr><tr><td><strong>Maximum rows per query</strong></td><td>10,000</td></tr><tr><td><strong>Maximum lookback period</strong></td><td>Your tenant's configured data retention period (commonly 14 days) — not a fixed API constant</td></tr><tr><td><strong>Minimum polling interval</strong></td><td>1 second (recommended: 2–3 seconds)</td></tr><tr><td><strong>Authentication token lifetime</strong></td><td>3,600 seconds (1 hour) — refresh before expiry</td></tr></tbody></table>

> **Bulk data retrieval:** If you need more than 10,000 rows, split your query into multiple time windows and submit them sequentially. For example, query 4-hour windows over a 24-hour period to retrieve up to 60,000 rows total.

#### Appendix A — Where to Find Table Schemas

This guide covers the query mechanics and provides sample queries, but does not document every available table and field. Full schema documentation — including all available tables, field names, types, and example values — is maintained separately.

> **📄 See the Vectra Investigate API Query Schema Reference** for complete field listings:
>
> Investigate API metadata schema reference

#### Appendix B — Quick Reference: Endpoint Summary

<table><thead><tr><th width="164.23828125">Action</th><th width="104.9140625">Method</th><th>Endpoint</th></tr></thead><tbody><tr><td>Obtain access token</td><td>POST</td><td><code>https://{brain}/oauth2/token</code></td></tr><tr><td>Submit a query</td><td>POST</td><td><code>https://{brain}/api/v3.4/investigations/</code></td></tr><tr><td>Poll for results</td><td>GET</td><td><code>https://{brain}/api/v3.4/investigations/{request_id}/</code></td></tr><tr><td>Get page 2+</td><td>GET</td><td><code>https://{brain}/api/v3.4/investigations/{request_id}/?page=2&#x26;page_size=500</code></td></tr></tbody></table>

#### Appendix C — Postman Tests Script (Auto-Capture Request ID)

Paste the following into the **Tests** tab of your POST request in Postman to automatically store the returned `request_id` as a collection variable. This eliminates the need to manually copy the ID between requests.

javascript

```javascript
pm.test("Status 200", function() {
  pm.response.to.have.status(200);
});

var json = pm.response.json();
if (json.request_id) {
  pm.collectionVariables.set("request_id", json.request_id);
  console.log("Captured request_id:", json.request_id);
}
```

**How to set this up in Postman:**

1. Open your POST `/api/v3.4/investigations/` request
2. Click the **Tests** tab (next to Headers, Body, etc.)
3. Paste the script above
4. In your GET request URL, use: `https://{{VECTRA_BRAIN}}/api/v3.4/investigations/{{request_id}}/`
5. Every time you run the POST request, `request_id` is automatically updated — then just send the GET request to poll for results

**Collection variables to configure:**

<table><thead><tr><th width="155.140625">Variable</th><th width="256.59765625">Example Value</th><th>Description</th></tr></thead><tbody><tr><td><code>VECTRA_BRAIN</code></td><td><code>your-brain.vectra.ai</code></td><td>Your Vectra Brain hostname</td></tr><tr><td><code>VECTRA_TOKEN</code></td><td><code>eyJhbGci...</code></td><td>OAuth Bearer token (refresh every hour)</td></tr><tr><td><code>request_id</code></td><td><em>(auto-populated by test script)</em></td><td>Captured from POST response automatically</td></tr></tbody></table>

> **Token auto-refresh tip:** Add a pre-request script to your collection to check token expiry and re-authenticate automatically if needed. Store the token expiry timestamp in a `TOKEN_EXPIRY` collection variable and compare against `Date.now()` before each request.

***

*Vectra AI — Investigations API User Guide | Version 1.1 | August 2026*


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.vectra.ai/operations/investigate/investigate-api-user-guide.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
