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

# API overview

> The Periphery JSON API: base URL, endpoints, the response fields, pagination, and how the contract changes.

The API exposes the same search and app records as the console, as JSON. To work with them from an AI assistant or agent instead, connect the [MCP server](https://docs.periphery.exposed/mcp/overview.md).

| | |
| - | - |
| Base URL | `https://api.periphery.exposed` (HTTPS only) |
| Version prefix | `/v1` |
| Format | JSON responses; parameters in the query string and path |
| Authentication | API key, see [Authentication](https://docs.periphery.exposed/api/authentication.md) |
| Plans | Plus |
| Changes | See the [changelog](https://docs.periphery.exposed/api/changelog.md) |

## Endpoints

| Endpoint | What it returns |
| - | - |
| [`GET /v1/apps?q=…`](https://docs.periphery.exposed/api-reference/search/search-apps) | Search the apps collection: a page of results for a query, using the console [query syntax](https://docs.periphery.exposed/search/query-syntax.md). `q` is required. |
| [`GET /v1/apps/{hostname}`](https://docs.periphery.exposed/api-reference/apps/get-an-app) | The full record for one app. The hostname is matched case-insensitively, and the record carries its lower-case form. `HEAD` works too. |

Both are described in full, with a playground, under **Endpoints** in the sidebar. `/v1/apps` (or `/v1/apps/`) takes `GET` only; any other method, `HEAD` included, answers `405`. Any other path under `/v1` answers `404`; see [Limits and errors](https://docs.periphery.exposed/api/limits-and-errors.md).

Every response, errors included, is JSON with `Content-Type: application/json` and `Cache-Control: no-store`.

## A first call

```bash theme={null}
curl "https://api.periphery.exposed/v1/apps?q=acme.com" \
  -H "Authorization: Bearer $PERIPHERY_API_KEY"
```

```json theme={null}
{
  "query": "domain:acme.com",
  "page": 1,
  "pageSize": 50,
  "hasMore": false,
  "total": 2,
  "totalCapped": false,
  "hits": [
    {
      "hostname": "acme-dashboard.vercel.app",
      "provider": "vercel",
      "status": 200,
      "title": "Acme dashboard",
      "firstSeen": "2026-10-21T09:12:05Z",
      "lastSeen": "2026-11-03T14:03:11Z",
      "scanned": true,
      "faviconHash": -247388890,
      "faviconUrl": "https://s3.gra.io.cloud.ovh.net/periphery/favicons/acme-dashboard.vercel.app.ico?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=3600&X-Amz-Signature=…",
      "screenshotUrl": "https://s3.gra.io.cloud.ovh.net/periphery/screenshots/acme-dashboard.vercel.app.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=3600&X-Amz-Signature=…",
      "emails": ["ops@acme.com"],
      "hasVerifiedCreds": true,
      "verifiedDetectors": ["Stripe"]
    },
    {
      "hostname": "acme-com-staging.vercel.app",
      "provider": "vercel",
      "status": 401,
      "title": "",
      "firstSeen": "2026-09-17T22:05:19Z",
      "lastSeen": "2026-11-04T08:41:02Z",
      "scanned": false,
      "hasVerifiedCreds": false
    }
  ]
}
```

The first hit has a full snapshot. The second was visited and answered `401`, but its page has not been captured yet, so it has no snapshot links, no `emails` and no `verifiedDetectors`.

## The response

| Key | Meaning |
| - | - |
| `query` | The query Periphery ran, in canonical form. See [The query field](#the-query-field). |
| `page` | The page served, starting at 1. |
| `pageSize` | Hits per page: always `50`. |
| `hasMore` | `true` when the next page has at least one hit. |
| `total` | Number of matching apps. See [Pagination](#pagination) for how exact it is. |
| `totalCapped` | `true` when counting stopped at 1,000,000: at least `total` apps match. |
| `hits` | Up to 50 apps, `[]` when none. |

Every key is always present.

`q` is required. A missing or blank `q` answers `400` and uses no search:

```json theme={null}
{"error": "q is required: give a search query", "code": "query_required"}
```

A `q` that reads as nothing, such as `status:2oo` alone, returns an empty page whose `query` is `""`, showing that the filter was dropped, and uses no search:

```json theme={null}
{"query": "", "page": 1, "pageSize": 50, "hasMore": false, "total": 0, "totalCapped": false, "hits": []}
```

## App fields

A search hit and an app record share the same fields, with the same values for the same hostname. The app record adds `text` and `credentials`.

| Field | Present | Meaning |
| - | - | - |
| `hostname` | always | The app's hostname, lower-case ASCII. Its stable identifier. |
| `provider` | always | Provider slug, as on the [providers page](https://docs.periphery.exposed/coverage/providers.md). New providers add new values. |
| `status` | always | HTTP status from the last render or fallback request that got an answer; `0` when none was recorded. A bot-wall page does not update it. |
| `title` | always | Page title; `""` when the page had none or was never captured. |
| `firstSeen` | when the host has ever answered | Time of the first visit that got an answer, RFC 3339 in UTC. It never changes. |
| `lastSeen` | always | Time of Periphery's last visit that did not prove the host dead, whether or not the host answered. Not the capture time. RFC 3339 in UTC. |
| `scanned` | always | `true` when Periphery captured the page and stored a snapshot. |
| `faviconHash` | when Periphery has the favicon | Shodan-compatible favicon hash. `0` is a valid hash. |
| `faviconUrl` | when Periphery has the favicon | Signed link to the stored favicon. An SVG favicon downloads when opened directly. |
| `screenshotUrl` | when there is a screenshot | Signed link to the stored screenshot. |
| `emails` | when the page was scanned for addresses | Email addresses found on the page, lower-cased, up to 100. `[]` when none were found. |
| `hasVerifiedCreds` | always | `true` when at least one credential finding was verified: `verifiedDetectors` is not empty. |
| `verifiedDetectors` | when the page was scanned for credentials | Names of the detectors with a verified finding, sorted alphabetically and listed once each, ignoring case. `[]` when none was verified. Each works as `creds:<name>` in a query. |
| `text` | always, app record only | Visible text extracted from the page, up to 2,048 bytes; `""` when none. |
| `credentials` | when the page was scanned for credentials, app record only | Each finding, `{detector, verified, masked}`, up to 20, verified first. `[]` when there were none. |

The rules behind the table: strings, numbers and booleans are always present. A timestamp is omitted when the event never happened. A list is `[]` when Periphery looked and found nothing, and omitted when it never looked. Signed links and `faviconHash` are omitted when there is nothing to link to. No field is ever `null`.

`scanned: false` means Periphery has visited the host but holds no snapshot of its page yet: every render so far failed or hit a bot wall. Its `status`, if any, comes from a fallback request. Such an app has no snapshot links and no lists, and its `title` and `text` are empty.

> [!WARNING]
>
> Credential findings only ever come masked, in `credentials[].masked`, and the raw page source the credential scan ran over is never served. The `text` field and the screenshot show what the page displayed when it was captured, so a secret the page printed on screen appears in them as shown. Treat responses as sensitive. See [Emails and credentials](https://docs.periphery.exposed/apps/emails-and-credentials.md).

### Signed links

`faviconUrl` and `screenshotUrl` are signed and expire after an hour: download what you want to keep. To refresh the links of one app, call [`GET /v1/apps/{hostname}`](https://docs.periphery.exposed/api-reference/apps/get-an-app): it returns fresh links without using a search, though it shares the one-request-per-second budget.

## Pagination

Search results come in pages of 50. Request another page with `page`:

```bash theme={null}
curl "https://api.periphery.exposed/v1/apps?q=acme.com&page=2" \
  -H "Authorization: Bearer $PERIPHERY_API_KEY"
```

Keep requesting the next page while `hasMore` is `true`, and stop when it is `false`. `hasMore` is exact: it is `true` only when the next page has at least one hit. A page past the end returns an empty `hits` array. A `page` that is not a number, or is below 1, is read as 1. Page 20,000 is the last one, and its `hasMore` is always `false`; a larger `page` answers `400 page_out_of_range`.

Each page is one search against your [monthly allowance](https://docs.periphery.exposed/api/limits-and-errors.md#monthly-searches), and the per-second rate limit applies. Pages up to 20,000 are allowed, but 3,000 searches a month read at most 3,000 pages, or 150,000 hits; narrow the query to reach what you need in fewer pages.

`total` comes from a count that Periphery keeps for up to 10 minutes, raised to at least what the page you are reading proves. Treat it as an estimate for progress bars, not as a stop rule. Counting stops at 1,000,000: past that, `total` reads `1000000` and `totalCapped` is `true`.

**Order.** A query with free text is ordered by relevance. A query of filters only is ordered by `firstSeen`, newest first, then by `lastSeen`, newest first; apps with no `firstSeen` come last.

**Pages are offsets over live data.** Apps enter and leave the index between your requests, so one app can appear on two pages, or move past you between them. De-duplicate by `hostname`. Each page is cached for up to a minute, and `total` for up to 10 minutes. `GET /v1/apps/{hostname}` reads the record live: when it disagrees with a search hit, the app record is the current one.

## The query field

`query` shows the query Periphery actually ran, in the canonical form of the [query syntax](https://docs.periphery.exposed/search/query-syntax.md). Filter values it could not read are dropped or searched as text, so compare it with what you sent to catch a typo:

```text theme={null}
q=acme.com status:abc firstSeen:<7d    →    "query": "domain:acme.com firstSeen:<1w"
```

Here `status:abc` was dropped, `acme.com` became a domain search, and `7d` was written as `1w`.

A value searched as text comes back as you sent it, with the free text at the start of `query`, so it can read like a filter. Filters follow the free text in a fixed order, and `lastSeen:` and `firstSeen:` windows come last. A window that applies always has `<` or `>`, a whole number of at least 1 and a unit of `h`, `d` or `w`, and is at most 15,250 weeks; a `lastSeen:` or `firstSeen:` token without those, or followed by anything other than another window, was searched as text:

```text theme={null}
q=acme lastSeen:<7d               →    "query": "acme lastSeen:<1w"           (window applied)
q=acme lastSeen:7d                →    "query": "acme lastSeen:7d"            (no < or >: text)
q=acme lastSeen:<30501w           →    "query": "acme lastSeen:<30501w"       (too large: text)
q=lastSeen:7d provider:vercel     →    "query": "lastSeen:7d provider:vercel" (before a filter: text)
```

## Credentials in responses

Every API key belongs to a Plus account, so responses carry what Plus sees: email addresses on search hits and app records, and on an app record the `credentials` array with every finding, verified or not, each value masked. `hasVerifiedCreds` and `verifiedDetectors` summarise it. The `has:creds` filter in `q` always applies. See [Emails and credentials](https://docs.periphery.exposed/apps/emails-and-credentials.md).

## Versioning and changes

The `/v1` contract changes in two ways:

* **Additive changes ship at any time**: new fields, new error codes, and new values in open lists such as `provider` slugs. Ignore fields and values you do not know rather than rejecting the response.
* **Removals, renames and changes of meaning** are announced at least 90 days ahead, in the [changelog](https://docs.periphery.exposed/api/changelog.md) and by email to every account that holds an API key.

The changes of 5 November 2026 shipped without notice because the API had not launched yet.

## Playground

Each endpoint page has a playground that sends real requests with the key you paste. They are rate-limited like any other request, and each search uses one of your monthly searches.

## Common automation patterns

See [API recipes](https://docs.periphery.exposed/api/recipes.md) for pagination, retrieving full records, handling rate limits and errors, and keeping credential-bearing responses out of logs and third-party tooling.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.