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

# Search

> Search the web and return ranked results

Search the web and return ranked results with URLs, titles, and descriptions.

Part of the [API reference](/api-reference), which covers the base URL, authentication, and the limits both endpoints share.

<CodeGroup>
  ```bash curl theme={"system"}
  curl -X POST "https://api.keenable.ai/v1/search" \
    -H "X-API-Key: keen_<your_key>" \
    -H "Content-Type: application/json" \
    -d '{ "query": "typescript best practices" }'
  ```

  ```python Python theme={"system"}
  import requests

  r = requests.post(
      "https://api.keenable.ai/v1/search",
      headers={"X-API-Key": "keen_<your_key>"},
      json={"query": "typescript best practices"},
  )
  for hit in r.json()["results"]:
      print(hit["title"], hit["url"])
  ```

  ```typescript TypeScript theme={"system"}
  const r = await fetch("https://api.keenable.ai/v1/search", {
    method: "POST",
    headers: {
      "X-API-Key": "keen_<your_key>",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ query: "typescript best practices" }),
  });
  const { results } = await r.json();
  ```
</CodeGroup>

## Without a key

`POST /v1/search/public` takes the same body and returns the same shape, with no API key:

```bash theme={"system"}
curl -X POST "https://api.keenable.ai/v1/search/public" \
  -H "X-Keenable-Title: My App" \
  -H "Content-Type: application/json" \
  -d '{ "query": "typescript best practices" }'
```

`X-Keenable-Title` names your application and is required on the public endpoint; without it the request is rejected with `400 Missing app identifier`. Keyless calls are rate limited per IP and consume no credits — see [Authentication](/api-reference#authentication) for the details. The playground on this page calls the keyed endpoint.

## Request

<ParamField body="query" type="string" required>
  The search query.
</ParamField>

<ParamField body="site" type="string">
  Restrict results to a specific site (e.g. `"techcrunch.com"`).
</ParamField>

<ParamField body="acquired_after" type="string">
  Filter to pages acquired/indexed at or after this point in time. See [Date and time filters](#date-and-time-filters) for accepted formats.
</ParamField>

<ParamField body="acquired_before" type="string">
  Filter to pages acquired/indexed at or before this point in time. See [Date and time filters](#date-and-time-filters) for accepted formats.
</ParamField>

<ParamField body="published_after" type="string">
  Filter to pages published at or after this point in time. See [Date and time filters](#date-and-time-filters) for accepted formats.
</ParamField>

<ParamField body="published_before" type="string">
  Filter to pages published at or before this point in time. See [Date and time filters](#date-and-time-filters) for accepted formats.
</ParamField>

<ParamField body="query_time" type="string">
  Search the index as it stood at this point in time: pages acquired after it are excluded. Accepts a timestamp or a date (a date resolves to `00:00:00` UTC, not to the end of the day). Also re-bases relative deltas — see [Point-in-time search](/mcp-server#point-in-time-search).
</ParamField>

<ParamField body="snippet_max_length" type="integer">
  Maximum length, in characters, of the `snippet` returned per result. Must be between 180 and 10000. When omitted, a default snippet length is used.
</ParamField>

<ParamField body="max_results" type="integer">
  Maximum number of results to return. Must be between 1 and 50. When omitted, up to 10 results are returned.
</ParamField>

## Response

<ResponseField name="query" type="string">
  The query that was searched.
</ResponseField>

<ResponseField name="results" type="array">
  List of search results.

  <Expandable title="result">
    <ResponseField name="title" type="string">
      Page title.
    </ResponseField>

    <ResponseField name="url" type="string">
      Page URL.
    </ResponseField>

    <ResponseField name="description" type="string">
      Short summary of the page.
    </ResponseField>

    <ResponseField name="snippet" type="string">
      Longer text excerpt from the page content (if available).
    </ResponseField>

    <ResponseField name="published_at" type="string">
      When the page was published, as an ISO 8601 timestamp in UTC (if available).
    </ResponseField>

    <ResponseField name="acquired_at" type="string">
      When the page was acquired/indexed, as an ISO 8601 timestamp in UTC (if available).
    </ResponseField>
  </Expandable>
</ResponseField>

### Example

```json theme={"system"}
{
  "query": "typescript best practices",
  "results": [
    {
      "title": "TypeScript Best Practices 2026",
      "url": "https://example.com/ts-best-practices",
      "description": "A comprehensive guide to modern TypeScript patterns and best practices.",
      "snippet": "TypeScript Best Practices 2026 Use strict mode, prefer interfaces over type aliases for object shapes...",
      "published_at": "2026-01-15T10:30:00Z",
      "acquired_at": "2026-01-16T08:12:34Z"
    }
  ]
}
```

### Date and time filters

`acquired_after`, `acquired_before`, `published_after`, and `published_before` each accept **one** of the following formats:

* **Date** in RFC 3339 `full-date` form (`YYYY-MM-DD`) — covers that whole day in UTC. On an `_after` bound it resolves to `00:00:00` on that date; on a `_before` bound it resolves to `23:59:59.999` on that date, so pages from the named day are kept at either end. Pass a timestamp instead to cut at an exact instant.
* **Timestamp** in ISO 8601 form (`YYYY-MM-DDTHH:MM:SS[.sss][±HH:MM]`). When a timezone offset is not provided, the timezone is interpreted as UTC.
* **Relative delta** (`<number><unit>`, e.g. `7d`, `30min`) — resolves to the request time minus the delta, truncated to minute precision, or to `query_time` minus the delta when that is set. Supported units: `min` (minutes), `h` (hours), `d` (days), `mo` (months), `y` (years).

Example values:

| Value                                                  | Resolves to                                             |
| ------------------------------------------------------ | ------------------------------------------------------- |
| `2026-01-15` on `acquired_after` / `published_after`   | `2026-01-15T00:00:00Z`                                  |
| `2026-01-15` on `acquired_before` / `published_before` | `2026-01-15T23:59:59.999Z`                              |
| `2026-01-15T10:30:00`                                  | `2026-01-15T10:30:00Z` (no offset → UTC)                |
| `2026-01-15T10:30:00Z`                                 | `2026-01-15T10:30:00Z`                                  |
| `2026-01-15T10:30:00.500-05:00`                        | `2026-01-15T15:30:00.500Z`                              |
| `7d`                                                   | 7 days before request time, truncated to the minute     |
| `30min`                                                | 30 minutes before request time, truncated to the minute |

Relative deltas may be combined with absolute values across the two bounds of a window:

```json theme={"system"}
{
  "query": "...",
  "published_after": "1y",
  "published_before": "6mo"
}
```

```json theme={"system"}
{
  "query": "...",
  "acquired_after": "2024-01-01",
  "acquired_before": "30d"
}
```

For example, at request time `2026-05-18T14:23:45Z`, `acquired_after: "2h"` resolves to `2026-05-18T12:23:00Z` — a document acquired at `12:22:59Z` is dropped, one acquired at `12:23:00Z` is kept.

Mind the difference between a date and a timestamp on a `_before` bound: `acquired_before: "2026-05-01"` keeps a page acquired at `2026-05-01T14:31:13Z`, while `acquired_before: "2026-05-01T00:00:00Z"` drops it. Use the date form to mean "up to and including that day", and the timestamp form to cut at midnight.
