How-to › Describe your API

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.

Audience
API producer
Level
advanced
Topic
Model resources, relations and actions
Verified

Read first: Design one filter and sort vocabulary for all resources

Support wants to find a customer by typing part of a name. Finance wants every invoice with “late fee” somewhere in the memo. Each request arrives as a proposal for one more query parameter on GET /customers or GET /invoices. A list endpoint that started with three exact filters is growing a contains operator, a relevance score, and a text index it was never built to read.

What you get

You will end up with a filter-or-search verdict for every requested query, the lag between a write and its appearance in search, and an SDK whose list method never reads the index. This is for you if your list endpoint keeps growing parameters.

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
Free-text q parameter on the list endpointA small API where one q over two text fields is all anyone has asked forTwo freshness promises on one endpoint, because the words need an index while the filters read the table, and a sort parameter that relevance order ignoresThe q results need ranking, or any caller reads the list right after a write
GitHub search with qualifiersOne search endpoint per kind of thing, with qualifiers such as repo: and is:open folded into qA 1,000-result ceiling, 30 requests a minute when authenticated, and best-match order unless the caller asks for anotherCallers need the complete set, which is a list with filters and pagination
Slack search.messagesSearch on behalf of a person, with that person’s permissions and preferences appliedA 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 legacyA service account has to search, or results must be the same for every caller
Stripe Search APIA /search beside each list, sharing the object shape and a query language of field clausesAn 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 secondThe 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 ceiling1 and a rate limit of its own. 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.

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.

node triage.mjs
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 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.

/** 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.

node lag.mjs
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 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

node --test api.test.mjs
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.

node pitfall.mjs
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 describes.

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.

Footnotes

  1. GitHub’s reference explains the ceiling with an analogy to searching on Google, 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. ↩︎ Back to text

  2. 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. ↩︎ Back to text

  3. Search needs a Stripe API version of 2020-08-27 or later, which the limitations section 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. ↩︎ Back to text

  4. The Zalando guidelines 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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.