# Search

> One query across events, classes, camps and places, with match levels and scores.

Source: https://api.tailend.dev/docs/search

Use `GET /v1/{metro}/search` when you do not know whether the answer is an
event, a class, a camp or a place. One query searches all four, with the same
visibility and filter rules as the individual list endpoints, so it never
returns a listing those endpoints would hide.

```bash
curl -G -H "X-API-Key: $TAILEND_API_KEY" \
  --data-urlencode "q=toddler story time" \
  --data "age=4" --data "near_zip=19148" \
  --data "from=2026-10-03" --data "to=2026-10-03" \
  https://api.tailend.dev/v1/philadelphia/search
```

```javascript
const params = new URLSearchParams({ q: "toddler story time", age: "4", near_zip: "19148", from: "2026-10-03", to: "2026-10-03" });
const res = await fetch(`https://api.tailend.dev/v1/philadelphia/search?${params}`, {
  headers: { "X-API-Key": process.env.TAILEND_API_KEY },
});
const { data, meta } = await res.json();
for (const r of data) console.log(r.type, r.name, r.match, r.score);
```

Each result keeps the shape of its own collection and adds `snippet`,
`match` and `score`. Snippets are plain text, without highlight markup.

## How your words are read

Search reads the query as plain words, and results can match by meaning as
well as by keyword.

- **Word forms match.** `swim` finds `swimming`.
- **Close relatives count.** A short list of related family-activity words
  matches too: `pottery` finds `ceramics`.
- **Filler is ignored.** Question and filler words such as "where can I",
  "with" or "for", and phrases such as "things to do", "near" and relative
  weekdays, are never required and never change a score.
- **Kind words only lift.** Words that name the kind of listing ("class",
  "lessons", "camp") lift a listing that has them in its name, but never
  decide whether it matches.
- **Time and price words are not filters yet.** "tonight" or "cheap" never
  decide a match; they come back in `meta.ignored_words`.
- **Audience words do not narrow.** "parents" or "kids" in a longer query come
  back in `meta.ignored_words`; use the `audience` filter instead. A query made
  only of audience words ("Mommy and Me", "kids", "family") is searched as
  written.

A query with nothing searchable in it ("near me", "where can I") returns
`400 bad_request`.

## Ages in the query

An age in the query becomes an age filter: "soccer for 5 year olds", "art
ages 3-5", "toddlers" and "teens" all narrow by age. The band that was applied
comes back in `meta.interpreted.age`, for example
`{ "min": 1, "max": 3, "from": "toddlers" }`.

Listings whose stated ages cover the band rank above listings with no stated
ages, and listings whose ages exclude it are left out. An `age` or `audience`
parameter you send yourself always wins: the band is not applied, and
`meta.interpreted.age.overridden_by` names your parameter.

## Match levels and score

Every result says how directly it matches in `match`:

| `match` | Meaning |
| --- | --- |
| `all_terms` | Every word you typed is in the listing's name or category, or prominent in the opening of its description. |
| `related` | Every word is covered that way, but at least one only through a closely related word. |
| `partial` | Only some of your words, or all of them only mentioned in passing further down a description. |

By default search returns `all_terms` and `related` results. Ask for loose
matches with `match=all_terms,related,partial` (or `match=partial` alone).
`meta.match_counts` reports how many results exist at each level whatever you
selected, so you can show "17 direct, 36 related" and offer to widen.
`meta.count_estimate` counts the levels you selected.

`score` is an absolute 0 to 1 measure of how well one listing fits the words
of the query. It is not scaled against the other results: the same listing
gets the same score whatever else comes back, and scores are comparable
between queries.

`meta.ranking` is `hybrid` when meaning-based ranking contributed, and
`keyword` when keyword ranking was used alone.

## Sorting

| `sort` | Order |
| --- | --- |
| `relevance` (default) | `all_terms`, then `related`, then `partial`. Within a level a name match ranks above a category match, which ranks above a description match. |
| `soonest` | By an event's next occurrence; places and undated results last, ties broken by relevance. |
| `nearest` | By distance. Requires `lat` + `lng` or `near_zip`. |
| `newest` | Most recently added first. |

```bash
curl -G -H "X-API-Key: $TAILEND_API_KEY" \
  --data-urlencode "q=storytime" --data "sort=nearest" \
  --data "near_zip=19148" \
  https://api.tailend.dev/v1/philadelphia/search
```

## Paging and limits

- Search cursors are tied to the sort and the ranking mode. Keep the same
  `sort` when you request the next page; changing it returns `400`. If the
  ranking mode changes between pages, you get `400 bad_request` with
  `details.reason: cursor_expired`: restart the search without the cursor.
- Search does not accept the collection paging and shaping parameters
  (`page`, `include`, `fields`, `expand`); sending one returns `400`.
- Very broad queries may be capped, and `meta.capped` is then `true`. Add
  filters or a more specific `q` to narrow the results.

See [Search every listing collection](https://api.tailend.dev/docs/api/search-listings.md) for every
parameter.
