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

# Structured Filters

> Understand filter, filter_id, operators, merge order, and query_config reuse

## Overview

Structured filters are the shared language for searches that need more than
simple exact-match query parameters. They power breach search, stealer search,
victim search, investigation fanout, AI-generated filters, and saved
`query_config` values for exports, bulk search, and scanners.

Use this guide to understand what `filter`, `filter_id`, operators, merge order,
and validation errors mean. Use the OpenAPI reference when you need the exact
request schema for one API operation.

## Start Here

Use the simplest option that fits your case:

1. If you only need simple exact filters like email, domain, country, or db name, use normal query params.
2. If you need `or`, `in`, `not_in`, `contains`, `starts_with`, `ends_with`, `wildcard`, range filters, or `exists`, use a structured `filter`.
3. If you want to type plain English like "US gmail users older than 18", use the AI filter endpoint and then search with `filter_id`.
4. If you are saving a search for scanners, bulk search, or exports, use `query_config`.

Recommended client pattern:

* keep simple exact rules as normal flat params
* switch to a structured `filter` when flat params are no longer enough
* keep `filter_id` only when there is no real filter to send yet

## 1. Simple Search: Use Normal Query Params

This is the easiest option. Use it when every condition is a simple exact match.

```bash theme={null}
curl -G "https://oathnet.org/api/service/v2/breach/search" \
  -H "x-api-key: YOUR_API_KEY" \
  --data-urlencode "q=alice@example.com" \
  --data-urlencode "country[]=US" \
  --data-urlencode "dbname[]=linkedin.com" \
  --data-urlencode "page_size=50"
```

Common flat params:

* breach: `email[]`, `email_domain[]`, `username[]`, `name[]`, `full_name[]`, `first_name[]`, `last_name[]`, `country[]`, `city[]`, `state[]`, `postal_code[]`, `address[]`, `dbname[]`, `phone[]`, `ip[]`, `discord[]`, `discord_id[]`, `social[]`, `financial[]`, `gaming[]`, `iban[]`, `ssn[]`, `date_birth_from`, `date_birth_to`
* stealer credentials: `domain[]`, `subdomain[]`, `path[]`, `username[]`, `password[]`, `email[]`, `email_domain[]`, `ip[]`, `hwid[]`, `discord_id[]`, `log_id`, `has_log_id`, `source_type[]`, `password_hash[]`, `archive_hash[]`, `canonical_credential_id[]`
* victims: `username[]`, `email[]`, `email_domain[]`, `ip[]`, `hwid[]`, `discord_id[]`, `country[]`, `city[]`, `os[]`, `service[]`, `steam_id[]`, `steam_name[]`, `phone[]`, `domain[]`, `subdomain[]`, `identity_state[]`, `victim_ip[]`, `antivirus[]`, `infection_path[]`, `total_docs_min`, `total_docs_max`, `service_count_min`, `service_count_max`, `view=enriched`
* investigation evidence: `property_type`, `service`, `identity_kind`, `account_id`, `username`, `display_name`, `value`, `domain`, `active`, `source_type`, `confidence`, `confidence_min`, `include_cookie_evidence`, `exclude_cookie_evidence`
* investigation files: `name`, `folder`, `kind`, `ext`, `size_min`, `size_max`

If you need any kind of nested logic, partial text match, range, or existence check, move to the next option.

## 2. Complex Search: Use `filter`

Use a structured `filter` when normal query params are not enough.

Good examples:

* `country is US OR CA`
* `email ends with @gmail.com`
* `age is at least 18`
* `instagram exists`
* `(country is US AND dbname is linkedin.com) OR password contains hunter`

You can send `filter` in two ways.

### Option A: GET with `filter` in the query string

Pass `filter` as a JSON-encoded string:

```bash theme={null}
curl -G "https://oathnet.org/api/service/v2/breach/search" \
  -H "x-api-key: YOUR_API_KEY" \
  --data-urlencode 'filter={"field":"country","operator":"eq","value":"US"}'
```

### Option B: POST to the same search route with a JSON body

Use this form for complex filters. The response shape is the same as `GET /search`.

```ts theme={null}
const response = await fetch(
  "https://oathnet.org/api/service/v2/breach/search?page_size=50&date_field=indexed_at",
  {
    method: "POST",
    headers: {
      "x-api-key": API_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      filter: {
        and: [
          { field: "country", operator: "eq", value: "US" },
          { field: "email", operator: "ends_with", value: "@gmail.com" },
        ],
      },
    }),
  }
);
```

The body only needs:

* `filter`
* `filter_id`

Things like `page_size`, `cursor`, `sort`, `from`, `to`, and `date_field` can stay in the query string.

## Investigation Section Filters

Investigation search accepts section-specific filters so one request can return a combined investigation workspace.

```json theme={null}
{
  "q": "example.com",
  "scope": "all",
  "include": ["credentials", "victims", "evidence", "files", "related_credentials"],
  "compact": true,
  "filters": {
    "credentials": {
      "domain": ["example.com"],
      "has_log_id": true
    },
    "victims": {
      "service": ["discord"],
      "country": ["US"]
    },
    "evidence": {
      "service": "discord",
      "confidence": ["high"]
    },
    "files": {
      "kind": "cookies"
    }
  },
  "cursors": {
    "credentials": null,
    "victims": null,
    "evidence": null,
    "files": null,
    "related_credentials": null
  }
}
```

Use `links` in the response to explain why records from different sections are related.

## 3. Natural Language: Use AI Filter Then Search

### Step 1: Ask the AI filter endpoint to build the filter

```bash theme={null}
curl -X POST "https://oathnet.org/api/service/v2/ai/filter" \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "index": "breach",
    "query": "US gmail users older than 18"
  }'
```

Example response:

```json theme={null}
{
  "filter_id": "0123456789abcdef01234567",
  "filter": {
    "and": [
      { "field": "country", "operator": "eq", "value": "US" },
      { "field": "email_domain", "operator": "eq", "value": "gmail.com" },
      { "field": "age", "operator": "gte", "value": "18" }
    ]
  }
}
```

### Step 2: Search with the returned `filter_id`

```bash theme={null}
curl -X POST "https://oathnet.org/api/service/v2/breach/search?page_size=50" \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "filter_id": "0123456789abcdef01234567"
  }'
```

### Step 3: Optional: edit the filter and send your own `filter`

If you want to change the AI result before searching, send a real `filter` instead of relying only on the stored context.

## 4. Saved Jobs: Use `query_config`

`query_config` is the JSON version of a search. It is used by:

* scanners
* bulk search
* exports

It may also carry client-side organization helpers such as `extra_params`, which are flattened into the eventual request when supported.

### Simple `query_config`

<Note>
  For JSON `query_config` objects, prefer canonical keys such as `domain`, `email_domain`, and `dbname`. Bracketed variants such as `domain[]` are still accepted for parity with query-string filters.
</Note>

```json theme={null}
{
  "q": "alice@example.com",
  "wildcard": true,
  "email": "alice@example.com",
  "dbname[]": ["collection_a", "collection_b"],
  "logic": "and",
  "extra_params": {
    "sort": "-indexed_at",
    "fields[]": ["email", "password", "dbname"],
    "view": "enriched"
  }
}
```

### Complex `query_config`

When the logic is too complex for simple flat keys, put a real structured filter inside `query_config.filter`.

```json theme={null}
{
  "filter": {
    "or": [
      {
        "and": [
          { "field": "email", "operator": "eq", "value": "alice@example.com" },
          { "field": "country", "operator": "eq", "value": "US" }
        ]
      },
      { "field": "password", "operator": "contains", "value": "hunter2" }
    ]
  }
}
```

## Filter Grammar

Every filter is built from just three shapes:

* a leaf rule with `field`
* an `and` group
* an `or` group

### Leaf node

```json theme={null}
{
  "field": "country",
  "operator": "eq",
  "value": "US"
}
```

### AND group

```json theme={null}
{
  "and": [
    { "field": "country", "operator": "eq", "value": "US" },
    { "field": "dbname", "operator": "eq", "value": "linkedin.com" }
  ]
}
```

### OR group

```json theme={null}
{
  "or": [
    { "field": "country", "operator": "eq", "value": "US" },
    { "field": "country", "operator": "eq", "value": "CA" }
  ]
}
```

### Nested example

```json theme={null}
{
  "and": [
    { "field": "country", "operator": "eq", "value": "US" },
    {
      "or": [
        { "field": "email", "operator": "ends_with", "value": "@gmail.com" },
        { "field": "email", "operator": "ends_with", "value": "@outlook.com" }
      ]
    }
  ]
}
```

## Merge Order

Requests support this merge order:

1. `filter_id` loads a stored transient filter context when it still exists.
2. An explicit `filter` replaces that stored filter.
3. Flat filters such as `email[]`, `domain[]`, `dbname[]`, or `log_id` are merged on top.
4. Pagination and sort settings such as `cursor`, `page_size`, `fields[]`, `from`, `to`, and `sort` still apply normally.

In simple terms:

* start from a saved or AI-generated `filter_id`
* send your own `filter` if you want to take control
* still add normal query params if needed

## Operator Guide In Plain Language

| Operator      | Value type   | Meaning                            | Notes                                                                                                 |
| ------------- | ------------ | ---------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `eq`          | string       | Exact or field-aware equality      | If the value contains `*` or `?`, it behaves like a wildcard pattern instead of a literal exact match |
| `neq`         | string       | Not equal                          | Inverts the normal equality translation                                                               |
| `in`          | string array | Match any listed value             | Array required                                                                                        |
| `not_in`      | string array | Exclude any listed value           | Array required                                                                                        |
| `contains`    | string       | Substring match                    | Rejected on date and IP fields                                                                        |
| `starts_with` | string       | Prefix match                       | Rejected on date and IP fields                                                                        |
| `ends_with`   | string       | Suffix match                       | Rejected on date and IP fields                                                                        |
| `wildcard`    | string       | Wildcard pattern using `*` and `?` | Rejected on date and IP fields                                                                        |
| `gt`          | string       | Greater than                       | Use stringified numeric/date values                                                                   |
| `gte`         | string       | Greater than or equal              | Use stringified numeric/date values                                                                   |
| `lt`          | string       | Less than                          | Use stringified numeric/date values                                                                   |
| `lte`         | string       | Less than or equal                 | Use stringified numeric/date values                                                                   |
| `exists`      | boolean      | Field exists or is missing         | `true` means present, `false` means missing, and omitted `value` defaults to `true`                   |

### Quick examples

```json theme={null}
{ "field": "country", "operator": "eq", "value": "US" }
```

```json theme={null}
{ "field": "country", "operator": "in", "value": ["US", "CA", "GB"] }
```

```json theme={null}
{ "field": "email", "operator": "ends_with", "value": "@gmail.com" }
```

```json theme={null}
{ "field": "instagram", "operator": "exists", "value": true }
```

```json theme={null}
{ "field": "age", "operator": "gte", "value": "18" }
```

## Common Cases

### Pattern searches

* `contains`, `ends_with`, and `wildcard` require at least `2` non-wildcard characters
* patterns like `"*"`, `"*a*"`, or `"?"` are rejected with `400`
* `starts_with` uses prefix semantics

```json theme={null}
{ "field": "username", "operator": "starts_with", "value": "admin" }
```

```json theme={null}
{ "field": "password", "operator": "contains", "value": "hunter" }
```

```json theme={null}
{ "field": "email", "operator": "wildcard", "value": "*@gmail.com" }
```

### Exists / missing checks

```json theme={null}
{ "field": "instagram", "operator": "exists", "value": true }
```

```json theme={null}
{ "field": "instagram", "operator": "exists", "value": false }
```

### Numeric and date ranges

Use string values that match the public field format:

* numeric-style fields such as `age` should use numeric strings like `"18"`
* date-style fields such as `date_birth` should use ISO-style dates like `"1990-01-01"`
* datetime fields such as `indexed_at` should use RFC3339 timestamps like `"2026-04-18T00:00:00Z"`

```json theme={null}
{ "field": "age", "operator": "gte", "value": "18" }
```

```json theme={null}
{ "field": "date_birth", "operator": "lt", "value": "1990-01-01" }
```

### `in` and `not_in`

Use arrays when you already know multiple values:

```json theme={null}
{ "field": "dbname", "operator": "in", "value": ["linkedin.com", "twitter.com"] }
```

```json theme={null}
{ "field": "country", "operator": "not_in", "value": ["US", "CA"] }
```

### Domain-only email filters

When you already know the mail domain, prefer the dedicated public field:

```json theme={null}
{ "field": "email_domain", "operator": "eq", "value": "gmail.com" }
```

## Step-By-Step Examples

### Example: exact filters only

```bash theme={null}
curl -G "https://oathnet.org/api/service/v2/stealer/search" \
  -H "x-api-key: YOUR_API_KEY" \
  --data-urlencode "domain[]=google.com" \
  --data-urlencode "email[]=admin@google.com" \
  --data-urlencode "has_log_id=true"
```

### Example: one structured rule

```json theme={null}
{ "field": "email", "operator": "ends_with", "value": "@gmail.com" }
```

### Example: AND two rules together

```json theme={null}
{
  "and": [
    { "field": "country", "operator": "eq", "value": "US" },
    { "field": "age", "operator": "gte", "value": "18" }
  ]
}
```

### Example: OR two rules together

```json theme={null}
{
  "or": [
    { "field": "email_domain", "operator": "eq", "value": "gmail.com" },
    { "field": "email_domain", "operator": "eq", "value": "outlook.com" }
  ]
}
```

### Example: nested logic

```json theme={null}
{
  "or": [
    {
      "and": [
        { "field": "country", "operator": "eq", "value": "US" },
        { "field": "dbname", "operator": "eq", "value": "linkedin.com" }
      ]
    },
    { "field": "password", "operator": "contains", "value": "hunter2" }
  ]
}
```

### Example: send the filter with POST

```bash theme={null}
curl -X POST "https://oathnet.org/api/service/v2/breach/search?page_size=50" \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "filter": {
      "or": [
        {
          "and": [
            { "field": "country", "operator": "eq", "value": "US" },
            { "field": "dbname", "operator": "eq", "value": "linkedin.com" }
          ]
        },
        { "field": "password", "operator": "contains", "value": "hunter2" }
      ]
    }
  }'
```

### Example: `filter_id` plus your own exact params

This is valid:

```bash theme={null}
curl -G "https://oathnet.org/api/service/v2/stealer/search" \
  -H "x-api-key: YOUR_API_KEY" \
  --data-urlencode 'filter_id=0123456789abcdef01234567' \
  --data-urlencode 'email[]=admin@google.com' \
  --data-urlencode 'has_log_id=true'
```

### Example: `filter_id` plus a replacement `filter`

This is also valid:

```ts theme={null}
await fetch("https://oathnet.org/api/service/v2/breach/search?page_size=50", {
  method: "POST",
  headers: {
    "x-api-key": API_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    filter_id: "0123456789abcdef01234567",
    filter: {
      field: "country",
      operator: "eq",
      value: "FR",
    },
  }),
});
```

## Choosing Fields

Field availability is endpoint-specific. A field accepted by breach search is not automatically valid for stealer or victims.

* breach search commonly uses fields such as `email`, `email_domain`, `username`, `country`, `city`, `dbname`, `date_birth`, and `age`
* stealer search commonly uses fields such as `domain`, `subdomain`, `path`, `username`, `password`, `email`, `ip`, `hwid`, `discord_id`, and `log_id`
* victims search commonly uses fields such as `username`, `email`, `ip`, `hwid`, `discord_id`, `log_id`, and `total_docs`

Use the workflow guides to understand each search surface, then use the OpenAPI reference for exact fields, parameters, schemas, and playground requests. The breach autocomplete endpoints help discover db names and field/value coverage.

## Scanners, Exports, And Bulk Search

`query_config` reuses the same filter system, with a few extra rules:

* `filter` can be a JSON object or a JSON string
* flat filters can be sent with canonical JSON keys such as `domain`, `email_domain`, or `dbname`
* bracketed keys such as `domain[]` are also accepted
* scanners reject runtime-only keys such as `from`, `to`, `cursor`, `page_size`, `format`, `debug`, and `search_id`
* scanners always monitor on `indexed_at`
* exports and bulk search allow `from`, `to`, `date_field`, and `sort` because those jobs serialize full searches

In practice:

* start flat when the saved search is simple
* switch to `query_config.filter` when the saved search becomes complex

## Limits

* `filter_id` must be a 24-character hex string
* maximum nesting depth is `2`
* maximum leaf conditions is `50`
* `value` is required for every operator except `exists`
* `in` and `not_in` require arrays

## Common 400 Errors

Typical structured-filter validation failures include:

* malformed JSON in `filter`
* a node that mixes `field` with `and` or `or`
* invalid operator names
* disallowed fields for the selected endpoint
* `contains`, `ends_with`, or `wildcard` patterns that are too short
* using pattern operators on date or IP fields
* nesting deeper than two levels
* more than fifty leaf rules

## Recommended Client Pattern

Use three practical rules when building an API client:

1. If a filter can be represented as normal exact-match params, keep it simple and send flat params.
2. If the filter needs nested logic or advanced operators, send a real structured `filter`.
3. If both `filter_id` and a real filter are available for a saved config, prefer the real filter as the canonical version.

## Related Guides

<CardGroup cols={2}>
  <Card title="Breach Search" icon="search" href="/guides/breach-search">
    Search breach data with structured filters
  </Card>

  <Card title="Stealer Search" icon="search" href="/guides/stealer-search">
    Search stealer records with flat or structured filters
  </Card>

  <Card title="Victim Workflows" icon="user" href="/guides/victims">
    Search victim summaries using the same filter grammar
  </Card>

  <Card title="API Reference" icon="code" href="/api-reference/overview">
    Use OpenAPI for exact filter request fields and playground examples
  </Card>
</CardGroup>
