# Search every listing collection

> Searches published, active listings in one metro.

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

```http
GET https://api.tailend.dev/v1/{metro}/search
X-API-Key: $TAILEND_API_KEY
```

Searches published, active listings in one metro. When semantic search
is enabled, results match by meaning as well as keywords.
Search applies the same visibility and filter rules as each list
endpoint, so it never returns a listing that endpoint would hide.
Results are ranked and paginated across collections.
Recurring events use their next occurrence.

**How words are matched.** The query is read as plain words: quotes,
a leading `-` and `OR` are not interpreted. Different forms of a word
match each other (`swim` finds `swimming`), and a short list of closely
related family-activity words also counts (`pottery` finds `ceramics`,
`baking` finds `cooking`). Question and filler words (`where can I`,
`with`, `for`, `the`) are ignored and never change a score. Words that
only name the kind of listing (`class`, `lessons`, `camp`) never decide
whether a listing matches; they only lift a listing that has them in its
name.

**Ages and audiences.** An age in the query becomes an age filter:
`5 year olds`, `ages 3-5`, `toddlers` (1 to 3), `baby` (0 to 1) and
`teens` (13 to 18). The band that was applied is echoed in
`meta.interpreted.age`. When the query gives an age, listings whose stated
ages cover it rank above listings with no stated ages; listings whose ages
exclude it are left out. An explicit `age` or `audience` parameter always
wins: the age in the words is then not applied, and
`meta.interpreted.age.overridden_by` says which parameter won. Time and
price words (`tonight`, `saturday`, `morning`, `cheap`) are not filters
yet: they never decide a match and are listed in `meta.ignored_words`.
Audience words
(`parents`, `kids`, `family`) do not narrow the search when other words
are present, and are listed in `meta.ignored_words`; a title that names
them still ranks a little higher. A query made only of such words is
never refused: `Mommy and Me`, `kids` and `family` search for those words
as written. A query with nothing searchable at all (`near me`,
`where can I`) returns `400`.

**How directly a result matches.** Every result carries `match`.
`all_terms`: every word you typed is in the listing's name or category,
or is 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 (`food` in a park concert that
sells snacks). Inside a level, a name match ranks above a category match,
which ranks above a description match. By default only `all_terms` and
`related` are returned, so a count reflects real matches; send
`match=all_terms,related,partial` (or `match=partial` alone) to include
the loose ones. `meta.match_counts` says how many results there are at
each level, whatever `match` you chose, so a client can say "17 direct,
36 related, 1,378 loose". Results are ordered so that `all_terms` come
before `related`, and those before `partial`, unless you choose another
`sort`.

`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, so a
weak best match no longer shows as 1, and the same listing gets the
same score whatever else is returned. It is not a probability. `snippet` is
a plain-text, unhighlighted excerpt of the listing's public description
(or name when no description exists), at most 180 characters.

`sort=relevance` is the default. `soonest` orders dated results by
their next occurrence (including recurring events), with places and
undated items after them. `nearest` orders by distance and requires
`lat` + `lng` or `near_zip`. `newest` orders by `created_at`. Every
order breaks ties by score, then id; cursors are bound to the sort and
ranking mode. If semantic availability changes between pages, the API
returns `400 bad_request` with `details.reason: cursor_expired`; restart
the search without a cursor so results are never silently skipped.

Authorization: send your key in the `X-API-Key` header. Available on every plan.

## Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `metro` | string | yes | Metro slug from /v1/metros (e.g. philadelphia). |

## Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `q` | string | yes | Natural-language or keyword query, read as plain words. |
| `types` | array of string | no | Repeatable or comma-separated collections; defaults to all four. |
| `sort` | string | no | Cross-collection ordering. |
| `match` | array of string | no | Which match levels to return, as a comma-separated list of all_terms, related and partial. |
| `from` | string (date) | no | Local calendar date (metro timezone), inclusive. |
| `to` | string (date) | no | Local calendar date (metro timezone), inclusive; at most 366 days after from. |
| `min_confidence` | number | no | Drop rows whose confidence is below this. |
| `category` | array of string | no | Keep rows whose categories contain any of these. |
| `audience` | array of string | no | Keep rows whose audience is one of these. |
| `age` | integer | no | A child's age in years. |
| `neighborhood` | string | no | Neighborhood slug or free-text name (case-insensitive). |
| `lat` | number | no | Center latitude for a radius search. |
| `lng` | number | no | Center longitude for a radius search. |
| `radius_m` | integer | no | Radius in metres around lat/lng (or near_zip, where enabled). |
| `zip` | array of string | no | Beta — may not be enabled for all keys yet. |
| `near_zip` | string | no | Beta — may not be enabled for all keys yet. |
| `include_approximate` | boolean | no | Beta — may not be enabled for all keys yet. |
| `free` | boolean | no | Events, classes, and camps only; ignored for places. |
| `max_price` | number | no | A budget. |
| `has_time` | boolean | no | true keeps only events with a stated clock time. |
| `recurring` | boolean | no | Filter on is_recurring. |
| `weekday` | array of string | no | Keep rows on any of these local days. |
| `season` | array of string | no | Events, classes and camps only. |
| `occasion` | string | no | Events and classes tagged for a seasonal occasion. |
| `team` | string | no | Events built around a local pro sports team (watch parties, game-day themed events, team or mascot appearances). |
| `start_after` | string | no | Local start time at or after this, HH:MM (or HH:MM:SS). |
| `start_before` | string | no | Local start time strictly before this, HH:MM; 09:00–12:00 is the morning and 12:00 belongs to the afternoon. |
| `organization_id` | string (uuid) | no | Keep rows published by this organization (organization.id). |
| `changed_since` | string (date-time) | no | Sync mode. |
| `place_id` | string (uuid) | no | Keep rows held at this place (place.id). |
| `openings` | boolean | no | Classes only. |
| `schedule_day` | string | no | Classes only; keep classes meeting on this weekday. |
| `enrollment_type` | string | no | Classes only; filter by enrollment model. |
| `year` | integer | no | Camps only; program year. |
| `overnight` | boolean | no | Camps only; filter overnight programs. |
| `type_hint` | string | no | Places only; filter by place type. |
| `has_birthday_parties` | boolean | no | Places only; filter advertised birthday-party offerings. |
| `has_classes` | boolean | no | Places only; filter places offering classes. |
| `indoor` | boolean | no | Events and classes only; see the indoor parameter on the event list. |
| `outdoor` | boolean | no | Events and classes only; see the outdoor parameter on the event list. |
| `sensory` | boolean | no | Events, classes and places; see the sensory parameter on the event list. |
| `cursor` | string | no | Opaque continuation token, bound to the query, types, filters, and sort. |
| `limit` | integer | no | Results per page. |

## Example request

```bash
curl -sS \
  -H "X-API-Key: $TAILEND_API_KEY" \
  "https://api.tailend.dev/v1/philadelphia/search?q=family%20story%20time"
```

## Responses

### 200

A page spanning the selected collections in the requested sort order.

```json
{
  "data": [
    {
      "type": "event",
      "id": "b81a95d2-1d4e-46fd-9cff-322a41e429ad",
      "slug": "toddler-storytime",
      "name": "Toddler Storytime",
      "snippet": "Stories and songs for children ages 2–5.",
      "match": "all_terms",
      "score": 0.92,
      "starts_local": "2026-10-03T10:00:00"
    }
  ],
  "meta": {
    "query": "toddler story time",
    "applied_filters": {
      "audience": [
        "not_adults"
      ],
      "by_type": {
        "events": {
          "from": "2026-09-28",
          "to": "2026-10-28",
          "min_confidence": 0.7
        },
        "classes": {
          "from": "2026-09-28",
          "to": null,
          "min_confidence": 0.7
        },
        "camps": {
          "from": "2026-09-28",
          "to": null,
          "min_confidence": 0.7
        },
        "places": {
          "min_confidence": 0.5
        }
      }
    },
    "defaults_applied": [
      "from",
      "to",
      "min_confidence",
      "audience"
    ],
    "types": [
      "events",
      "classes",
      "camps",
      "places"
    ],
    "ranking": "keyword",
    "ignored_words": [],
    "interpreted": {
      "age": {
        "min": 1,
        "max": 3,
        "from": "toddlers"
      }
    },
    "match": [
      "all_terms",
      "related"
    ],
    "match_counts": {
      "all_terms": 1,
      "related": 0,
      "partial": 14
    },
    "sort": "relevance",
    "limit": 20,
    "count": 1,
    "count_estimate": 1,
    "capped": false,
    "has_more": false,
    "next_cursor": null,
    "generated_at": "2026-09-28T14:30:00Z"
  }
}
```

### 400

Malformed or contradictory parameters (lat without lng, to before from, unknown category), or a cursor this API did not issue for this exact request — replayed from another endpoint, resource, metro or filter set, hand-crafted, or altered.

```json
{
  "error": {
    "code": "bad_request",
    "message": "radius_m: must be at least 100",
    "details": {
      "param": "radius_m"
    },
    "request_id": "req_dfbc3295a8382b05"
  }
}
```

### 401

Missing, unknown or revoked X-API-Key.

```json
{
  "error": {
    "code": "unauthorized",
    "message": "missing X-API-Key header",
    "request_id": "req_90e63908273f6bff"
  }
}
```

### 403

Two different reasons share this status.

```json
{
  "error": {
    "code": "forbidden",
    "message": "key is not scoped to metro 'boston'",
    "details": {
      "metros": [
        "philadelphia"
      ]
    },
    "request_id": "req_9e7a750acef48ab2"
  }
}
```

### 404

No published, active resource with that id (or unknown metro).

```json
{
  "error": {
    "code": "not_found",
    "message": "unknown metro 'boston'",
    "request_id": "req_7431b51e10ec2b77"
  }
}
```

### 405

The path exists; the method does not.

```json
{
  "error": {
    "code": "method_not_allowed",
    "message": "POST is not allowed on this path; this API is read-only",
    "details": {
      "allow": [
        "GET",
        "HEAD",
        "OPTIONS"
      ]
    },
    "request_id": "req_01J9Z3"
  }
}
```

### 429

A limit was reached.

```json
{
  "error": {
    "code": "rate_limited",
    "message": "3 requests per minute exceeded",
    "request_id": "req_06bfd3c2d5e8aa9f"
  }
}
```

### 500

A bug on our side.

```json
{
  "error": {
    "code": "internal",
    "message": "internal error",
    "request_id": "req_01J9Z3"
  }
}
```

### 501

This endpoint or feature is not available and will not become available without a configuration change — for ?address=, this means no geocoder is configured.

```json
{
  "error": {
    "code": "not_implemented",
    "message": "this endpoint is not enabled on this deployment; it is not a fault and retrying will not help",
    "request_id": "req_01J9Z3"
  }
}
```

### 503

A transient outage: a dependency could not be reached or did not answer in time.

```json
{
  "error": {
    "code": "unavailable",
    "message": "the service is temporarily unable to serve this request; retry after the interval in Retry-After",
    "request_id": "req_01J9Z3"
  }
}
```
