# How to decide when a list filter should become a search endpoint

> Triage requested queries into filters and searches, measure how long the search index trails a write, and keep an SDK list method off the search endpoint.

Source: https://voxgig.com/howto/when-a-filter-becomes-a-search-endpoint

- Audience: api-producer
- Level: advanced
- Verified: 2026-10-21
- Published: 2026-10-18

## Short answer

Add a query to the list endpoint when it is an exact or range match on named fields and the caller needs to see a row it wrote. Open a search endpoint when the query matches words, spans fields, or ranks results, because that reads an index that trails writes, as Stripe documents at under a minute. Give the search endpoint its own query language, pagination, and freshness statement, and never route an SDK's list method through it.

---
## You will need

Node 22 or later, and the list of queries your clients have asked for. Verified 2026-10-21
against Node 22.22.0. The sample is an in-process model of one API with two read paths, a table
and an index, on a virtual clock. It installs nothing and prints the same numbers on every run.
Nothing here calls GitHub, Stripe, or Slack. Their behavior is read from the documentation each
row of the table links.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Free-text `q` parameter on the list endpoint](https://opensource.zalando.com/restful-api-guidelines/#137) | A small API where one `q` over two text fields is all anyone has asked for | Two freshness promises on one endpoint, because the words need an index while the filters read the table, and a `sort` parameter that relevance order ignores | The `q` results need ranking, or any caller reads the list right after a write |
| [GitHub search with qualifiers](https://docs.github.com/en/rest/search/search?apiVersion=2022-11-28#search-issues-and-pull-requests) | One search endpoint per kind of thing, with qualifiers such as `repo:` and `is:open` folded into `q` | A 1,000-result ceiling, 30 requests a minute when authenticated, and best-match order unless the caller asks for another | Callers need the complete set, which is a list with filters and pagination |
| [Slack `search.messages`](https://docs.slack.dev/reference/methods/search.messages/) | Search on behalf of a person, with that person's permissions and preferences applied | A user token with `search:read`, so a bot cannot call it, results shaped by that user's search filters in the Slack UI, and a method Slack's reference marks as legacy | A service account has to search, or results must be the same for every caller |
| [Stripe Search API](https://docs.stripe.com/search) | A `/search` beside each list, sharing the object shape and a query language of field clauses | An index that trails writes by under a minute in normal operation, a cursor scheme unlike the list endpoints', and a limit of 20 operations a second | The caller reads a row it wrote moments ago, which Stripe sends to the list APIs |

Stripe and GitHub both split search from list and pay for the split in the open. Stripe pays with
a documented lag and a second pagination scheme, GitHub with a result ceiling[^1] and a
[rate limit of its own](https://docs.github.com/en/rest/search/search?apiVersion=2022-11-28#rate-limit).
Slack makes search something a person does, so a bot is locked out and the results depend on who
is asking.[^2] A `q` on the list endpoint avoids the second URL, and in exchange it mixes an
eventually consistent read into an endpoint whose other parameters are consistent.

## Triage the requested queries before any becomes a parameter

A query is a filter when it is an exact, range, or prefix match on named fields. An index on
those fields answers it from the table in the declared order, and the row a client wrote a moment
ago is in the result. A query is a search when it matches words, spans fields, needs a `contains`
match, or wants ranking. Each of those needs a text index, and a text index is refreshed behind
the writes. A query that is a search and is also read right after a write is a conflict, and the
triage says so rather than picking a side.

```js title="triage.mjs"
export function classify(q) {
  const anyField = q.fields.includes('*')
  const search = anyField || q.ranked || !FILTER_MATCHES.has(q.match)
  const why = anyField ? 'matches across fields' : q.ranked ? 'wants ranking' : !FILTER_MATCHES.has(q.match) ? `${q.match} match needs a text index` : `${q.match} match on declared fields`
  if (search && q.afterWrite) return { verdict: 'conflict', why: `${why}, and is read right after a write` }
  return { verdict: search ? 'search' : 'filter', why }
}
```

`queries.json` holds eight requests as they arrived, each reduced to the fields it names, the kind
of match, whether the asker wants ranking, and whether the result is read right after a write.

```bash
node triage.mjs
```

```text output
query                                             fields           match    ranked  after write  verdict
customer by exact email                           email            exact    no      yes          filter  (exact match on declared fields)
orders open since the first of the month          status,created   range    no      no           filter  (range match on declared fields)
customers whose name starts with Ro               name             prefix   no      no           filter  (prefix match on declared fields)
customers mentioning rocket anywhere              *                words    yes     no           search  (matches across fields)
invoices with late fee in the memo                memo             contains no      no           search  (contains match needs a text index)
did the import land, by external_ref              external_ref     exact    no      yes          filter  (exact match on declared fields)
support types amy rocketrides                     *                words    yes     no           search  (matches across fields)
find the customer I created by part of the name   name             contains no      yes          conflict  (contains match needs a text index, and is read right after a write)

8 queries: 4 filters, 3 searches, 1 conflict
  find the customer I created by part of the name: offer a filter on the fields the client already holds, and keep the words for the search endpoint
```

Four of the eight stay on the list endpoint, and three go to a search endpoint that does not
exist yet. The conflict row is the request that starts most of these arguments. The client that
created the customer holds the id and the email from the create response, so the filter on those
fields is its answer. The words belong to the search endpoint, for the people who hold neither.
The command exits 1 while a conflict stands, so the triage
can run in CI against the file where the requests accumulate.

## Measure the lag before you write the freshness sentence

Stripe states the lag in its documentation: under normal operating conditions, data is
searchable in under a minute, and [the same page](https://docs.stripe.com/search#data-freshness)
tells you not to use search for read-after-write flows. A number like that is measured, so
measure yours. The model API has a table that the list endpoint reads and an index that the
search endpoint reads. The indexer moves a write into the index after a fixed interval of virtual
time.

```js title="api.mjs"
/** POST /customers: written to the table at once, indexed indexLagMs later. */
create(fields) {
  const row = { id: `cus_${++sequence}`, created: clock.now, ...fields }
  table.push(row)
  pending.push({ row, visibleAt: clock.now + indexLagMs })
  return row
},

/** GET /customers?email=...: exact filters over the table, in the declared order. */
list({ email } = {}) {
  const rows = email ? table.filter((r) => r.email === email) : table
  return { data: rows.slice().sort(newestFirst), freshness: 'read-after-write' }
},

/** GET /customers/search?query=...: the index, ranked by how many terms a row matches. */
search(query) {
  refresh()
  const clauses = parseQuery(query)
  const scored = index
    .map((row) => ({ row, score: score(row, clauses) }))
    .filter((hit) => hit.score > 0)
    .sort((a, b) => b.score - a.score || newestFirst(a.row, b.row))
  return { data: scored.map((hit) => hit.row), freshness: `index trails writes by up to ${indexLagMs} ms` }
},
```

Each read path returns its own `freshness`, because the two are different promises and the
response is where a client finds out. The probe creates one customer, reads it back through both
paths, and polls the search path until the row appears or a minute has passed.

```bash
node lag.mjs
```

```text output
create  cus_1 at t=0 ms
list    email=amy@rocketrides.io          -> 1 result  (read-after-write)
search  email:"amy@rocketrides.io"         -> 0 results at t=0 ms
  poll 1  t= 250 ms  -> 0 results
  poll 2  t= 500 ms  -> 0 results
  poll 3  t= 750 ms  -> 0 results
  poll 4  t=1000 ms  -> 1 result
visible in search after 4 polls, 1000 ms after the write
document it as: index trails writes by up to 1000 ms
```

The list path sees the row at the instant of the write. The search path sees it on the fourth
poll, a second later, and that second is the sentence to put in the documentation beside the
search endpoint. Against a real service, run the probe from a staging environment many times
across a day. Publish the worst case you saw with its condition attached, as Stripe does by
qualifying its figure with normal operating conditions. The search endpoint also carries its own
pagination. Stripe's [customer search](https://docs.stripe.com/api/customers/search) resumes from
an opaque `page` token that each response returns as `next_page`, where its lists resume after an
object id given as `starting_after`.[^3]

## Check it worked

```bash
node --test api.test.mjs
```

```text output
1..10
# tests 10
# suites 0
# pass 10
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 294.587906
```

The line to read is `# pass 10`. The first test advances the clock to one millisecond short of
the lag and asserts the search still returns nothing, then advances one more and asserts the row.
The seventh runs the signup flow below twice and asserts two rows in one table and one in the
other. The last runs the probe against an index that never catches up and expects exit code 1 at
the deadline.

## When it goes wrong

A customer is created twice, with one signup. The SDK's `customers.list()` was wired to the search
endpoint because the search endpoint takes the same `email` and ranks its results. The signup
flow creates the customer, lists by email to confirm, finds nothing, concludes the create failed,
and creates again. `pitfall.mjs` runs that flow through two SDKs that differ only in which
endpoint `list()` calls.

```bash
node pitfall.mjs
```

```text output
SDK whose list() calls the search endpoint
  create amy@rocketrides.io   -> cus_1
  list(email) right after     -> 0 results
  client concludes the create failed and retries
  create amy@rocketrides.io   -> cus_2
  1000 ms later, search email:"amy@rocketrides.io" -> cus_2, cus_1
  rows in the table: 2
SDK whose list() calls the list endpoint
  create amy@rocketrides.io   -> cus_1
  list(email) right after     -> 1 result
  1000 ms later, search email:"amy@rocketrides.io" -> cus_1
  rows in the table: 1
```

The first SDK ends with two customers for one person, and the index catches up a second later to
show both. The fix is in the SDK's naming: `list()` reads the table, `search()` reads the index,
and the two are never aliases of each other.

A list response goes stale the day `q` is added to it. A caller sends `status=open&q=rocket`
right after an update and sees the row as it was, because once words are in the request the
server answers from the index. Refuse `q` beside the exact filters, or move it to the search
endpoint, so that every list response keeps the read-after-write promise.[^4]

The documented lag turns out to be the median. The probe ran at a quiet hour and the docs say one
second, then a re-index or an outage stretches it to minutes and clients built around the second
start retrying creates. Publish the bound with its condition, as Stripe does, and tell clients
which endpoint to use when they need the row they wrote.

## When not to do this

Do not open a search endpoint when every row of your triage says filter. A search index is an
operational system with its own lag, its own re-indexing, and its own ranking to tune. An exact
match on a declared field needs none of that.

Do not route an SDK's `list()` through search to save a method. The duplicate customer in the
pitfall is the whole case, and it appears whenever the confirming read lands inside the lag.

The lag you measured is a bound under normal operation, not a constant, and the sentence beside
the endpoint has to say so.

Do not use the search endpoint to enumerate a collection. GitHub caps a search at 1,000 results
and Slack at 100 pages of 100, and a ranked order can change between two requests for the same
page. Counting, exporting, and walking every row are list operations. They take filters and the
pagination that
[choose a pagination style for a list endpoint](/howto/choose-a-pagination-style-for-a-list-endpoint)
describes.

## Related how-tos

- [Decide if an endpoint is an entity operation or an action](/howto/entity-operation-or-standalone-action)

- [Choose a pagination style for a list endpoint](/howto/choose-a-pagination-style-for-a-list-endpoint)

- [Encode and sign opaque pagination cursors](/howto/encode-and-sign-opaque-pagination-cursors)

- [Design one filter and sort vocabulary for all resources](/howto/consistent-filtering-and-sorting-parameters)

- [Add field selection to resource responses](/howto/add-field-selection-to-responses)

## Last verified

Verified 2026-10-21 against Node 22.22.0. Every output block is what the command preceding it
printed, run in the page's code directory. The API is an in-process model made of a table and an
index that catches up after a fixed interval of virtual time. Every call is made in one process
rather than over a network. The lag numbers
describe the model and not any vendor. Stripe's, GitHub's, and Slack's figures were read from the documentation
this page links and were not measured.

[^1]: GitHub's reference explains the ceiling with [an analogy to searching on Google](https://docs.github.com/en/rest/search/search?apiVersion=2022-11-28#about-search),
where a person wants a few pages of results to find the item that best meets their needs. It then
states that the REST API provides up to 1,000 results for each search. The limit is explained by
what a person does with a search box, in the documentation of an endpoint that programs call.

[^2]: [`search.messages`](https://docs.slack.dev/reference/methods/search.messages/) takes a
`count` of at most 100 and a `page` of at most 100, so numbered pages reach 10,000 messages for
one query. Its rate limit is listed as Tier 2, written on the page as 20+ per minute, a figure with
a plus sign on it.

[^3]: Search needs a Stripe API version of 2020-08-27 or later, which
[the limitations section](https://docs.stripe.com/search#limitations) says an account on an
earlier version can satisfy per request by sending the `Stripe-Version` header. The feature
arrived pinned to a version. An integration on an earlier version reaches it by naming a later one
on each call and leaves the rest of its behavior where it was.

[^4]: The [Zalando guidelines](https://opensource.zalando.com/restful-api-guidelines/#137)
reserve `q` as the default query parameter and describe it as the one browser tab completion
uses. The same line asks that it have an entity-specific alias such as `sku`.