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

# Search

> Filters, sorting, pagination and result depth.

Search is how you find entities you cannot name yet. Every entity has a `search` endpoint that
takes the same request shape, so learning one teaches you all five.

```bash theme={null}
curl -X POST https://api.unif.dev/v1/shops/search \
  -H "Authorization: Bearer $UNIF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "market": "US",
    "period": { "preset": "last_30d" },
    "currency": "USD",
    "filters": {
      "category_ids": ["cat_beauty_personal_care"],
      "revenue": { "gte": 100000 },
      "revenue_growth_rate": { "gte": 0.25 }
    },
    "sort": [{ "field": "revenue_growth_rate", "direction": "desc" }],
    "limit": 25
  }'
```

## Why POST

Search takes a JSON body rather than query parameters. Filter sets are nested and open-ended —
ranges, arrays, several keys at once — and URL-encoding them produces requests that are long,
fragile and miserable to debug. A body keeps a filter set readable, diffable and storable as the
same JSON you can replay into a [tracker](/docs/workflows/trackers) later.

<Note>
  These POSTs are read-only and have no side effects. They are safe to retry, and they do not
  need an `Idempotency-Key`.
</Note>

## Range filters

Numeric and date filters take a range object. Combine keys to close a range on both sides.

| Key   | Meaning               |
| ----- | --------------------- |
| `gte` | Greater than or equal |
| `gt`  | Strictly greater than |
| `lte` | Less than or equal    |
| `lt`  | Strictly less than    |

```json theme={null}
{
  "filters": {
    "price": { "gte": 15, "lte": 45 },
    "revenue": { "gte": 100000 },
    "launched_at": { "gte": "2026-06-01" }
  }
}
```

Filters combine with **AND**. Array filters like `category_ids` are **OR** within the array — the
example below means "beauty or wellness, priced 15 to 45".

```json theme={null}
{
  "filters": {
    "category_ids": ["cat_beauty_personal_care", "cat_health_wellness"],
    "price": { "gte": 15, "lte": 45 }
  }
}
```

There is no `OR` across different filter keys. When you need one, run two searches and merge on
`id` — it is clearer than a query language nobody can read six months later.

## Sorting

`sort` takes up to three keys, applied in order.

```json theme={null}
{
  "sort": [
    { "field": "revenue_growth_rate", "direction": "desc" },
    { "field": "revenue", "direction": "desc" }
  ]
}
```

The second key breaks ties on the first, which matters more than it sounds: ranking purely on a
growth rate floats tiny shops that went from $80 to $400. A revenue floor in `filters` plus
revenue as a tiebreaker gives a list a human would agree with.

<Tip>
  Sorting is stable across pages for a given cursor. Changing `sort` invalidates a cursor — start
  a new search rather than paging on with the old one.
</Tip>

## Pagination

Responses are cursor-paginated. Read `next_cursor` and stop when `has_more` is false.

```python theme={null}
def iter_products(session, body):
    cursor = None
    while True:
        payload = {**body, "limit": 100}
        if cursor:
            payload["cursor"] = cursor
        page = session.post(
            "https://api.unif.dev/v1/products/search", json=payload, timeout=30
        ).json()

        yield from page["data"]

        if not page["has_more"]:
            return
        cursor = page["next_cursor"]
```

Never construct a cursor yourself, and never assume it encodes an offset. See
[Pagination](/docs/platform/pagination) for cursor lifetime and page-size trade-offs.

## Result depth

A search pages to a depth of **1,000 rows**. Past that, `has_more` is false even when more
entities match — `total_count` tells you how many there were.

This is a real limit, not a tuning knob: ranked commerce data is served as a ranking, and depth
beyond the top of it is not meaningfully ordered.

<AccordionGroup>
  <Accordion title="Narrow the filters" icon="filter">
    A revenue floor or a tighter price band usually turns 40,000 matches into the few hundred you
    actually wanted. This is the right fix most of the time.
  </Accordion>

  <Accordion title="Shard the search" icon="grid-2">
    Split by category, market or period and union the results. Each shard gets its own 1,000 rows,
    and sharding by leaf category is normally enough to cover a whole market.
  </Accordion>

  <Accordion title="Hand it to a job" icon="layer-group">
    An [export job](/docs/workflows/jobs) shards for you and returns CSV or JSONL. Use it for
    anything that feeds a warehouse.
  </Accordion>
</AccordionGroup>

## Zero results

An empty `data` array with `total_count: 0` is a finding, not an error — and it is **free**. Zero-
result searches cost no credits.

If a search returns unexpectedly little, check these in order:

<Steps>
  <Step title="Is the market entitled?">
    An unentitled market returns `coverage_error`, not an empty list. If you got a `200`, this is
    not the problem.
  </Step>

  <Step title="Is every filter field filterable here?">
    A `partial` field that is filterable will silently exclude entities where it is missing. Check
    [`GET /coverage`](/docs/concepts/coverage).
  </Step>

  <Step title="Was the period adjusted?">
    If `period.adjusted` is true, you measured a wider window than you asked for, and your growth
    filters mean something slightly different.
  </Step>

  <Step title="Are the thresholds in the right currency?">
    `"revenue": { "gte": 100000 }` is 100,000 units of your requested `currency`. In `IDR` that is
    a very different bar than in `USD`.
  </Step>
</Steps>
