> ## 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.

# Search apps

> Runs a query in the console's syntax and returns one page of up to 50
hits. Free text, bare domains, and `key:value` filters work exactly
as in the search bar; see the filter reference in the guides.

`q` is required: a missing or blank `q` is answered
`400 query_required`. A `q` that parses to nothing (say, only a
filter whose value cannot be read, like `status:2oo`) returns an
empty page whose `query` is `""`, showing the filter was dropped.
Neither is counted.

Each request counts as one search against the account's monthly
allowance, page by page, and the per-second rate limit applies.
Requests refused before the quota check (`400`, `401`, `403`, `405`,
`429`) never count a search; a `502`, `503` or `504` after it does. `has:creds` always applies, since API access and
the filter are both Plus features.

Hits are ordered by relevance when the query has free text, and
otherwise by `firstSeen`, newest first, then `lastSeen`, newest first.
Pages are offsets over live data, so an app can move between pages
while you page: deduplicate by `hostname`. Stop when `hasMore` is
false. Only `GET` is allowed; any other method, `HEAD` included, is
answered `405` before the key is checked and never runs a search.




## OpenAPI

````yaml /openapi.yaml get /v1/apps
openapi: 3.1.0
info:
  title: Periphery API
  version: 1.0.0
  summary: Search Periphery's index of publicly reachable web apps as JSON.
  description: |
    The API returns the same search results and app records as the console.
    Every request needs an API key issued on the console's account page,
    sent as `Authorization: Bearer <key>` or `X-API-Key: <key>`, over HTTPS
    (plain HTTP is refused with `403 https_required`). API access is part of
    the Plus plan. Each search request counts against the account's monthly
    allowance (3,000 on Plus, shared with the console), and requests are
    limited to one per second per account and ten per second per address.

    A search hit is an `AppSummary`; an app record (`App`) is the same
    summary plus `text` and `credentials`. A hostname reads the same on both
    endpoints for every field they share.

    Every response, success or error, is `application/json` with
    `Cache-Control: no-store`. Every error body is
    `{"error": "<message>", "code": "<code>"}`: switch on `code`, which is
    stable; the message may change. New fields, codes and enum values (such
    as provider slugs) may be added at any time, so ignore what you don't
    know.

    Credential findings are only ever returned masked, and the raw page
    source the credential scan ran over is never served. `text` and
    `screenshotUrl` show what the page displayed when it was captured, so a
    secret printed on the page appears in them as shown. Treat responses as
    sensitive.
  contact:
    name: Periphery
    email: contact@periphery.exposed
    url: https://docs.periphery.exposed
servers:
  - url: https://api.periphery.exposed
    description: Production
security:
  - bearerAuth: []
  - apiKeyHeader: []
tags:
  - name: Search
    description: Search the apps collection (`GET /v1/apps?q=…`) and page through the hits.
  - name: Apps
    description: Read one app's full record (`GET /v1/apps/{hostname}`).
paths:
  /v1/apps:
    get:
      tags:
        - Search
      summary: Search apps
      description: >
        Runs a query in the console's syntax and returns one page of up to 50

        hits. Free text, bare domains, and `key:value` filters work exactly

        as in the search bar; see the filter reference in the guides.


        `q` is required: a missing or blank `q` is answered

        `400 query_required`. A `q` that parses to nothing (say, only a

        filter whose value cannot be read, like `status:2oo`) returns an

        empty page whose `query` is `""`, showing the filter was dropped.

        Neither is counted.


        Each request counts as one search against the account's monthly

        allowance, page by page, and the per-second rate limit applies.

        Requests refused before the quota check (`400`, `401`, `403`, `405`,

        `429`) never count a search; a `502`, `503` or `504` after it does.
        `has:creds` always applies, since API access and

        the filter are both Plus features.


        Hits are ordered by relevance when the query has free text, and

        otherwise by `firstSeen`, newest first, then `lastSeen`, newest first.

        Pages are offsets over live data, so an app can move between pages

        while you page: deduplicate by `hostname`. Stop when `hasMore` is

        false. Only `GET` is allowed; any other method, `HEAD` included, is

        answered `405` before the key is checked and never runs a search.
      operationId: search
      parameters:
        - name: q
          in: query
          required: true
          description: >-
            The query, in the console's search syntax. Up to 256 characters
            (Unicode code points, not bytes) and 10 terms, where each free-text
            word and each filter value counts as one term. A missing or blank
            `q` is answered `400 query_required` and is not counted.
          schema:
            type: string
            minLength: 1
            maxLength: 256
            pattern: \S
          example: acme.com provider:vercel
        - name: page
          in: query
          required: false
          description: >-
            Page number, from 1 to 20000. Each page holds 50 hits. A page past
            the end returns an empty `hits` array. Values below 1 or that are
            not numbers are treated as 1. A larger `page`, including numbers too
            large to read, is answered `400 page_out_of_range`. Unlike the
            console, which stops at page 20, the API pages through the full
            result set, but every page costs one search from the monthly
            allowance.
          schema:
            type: integer
            minimum: 1
            maximum: 20000
            default: 1
          example: 1
      responses:
        '200':
          description: One page of results.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SearchResponse'
              examples:
                page:
                  summary: A page of hits
                  value:
                    query: provider:vercel domain:acme.com
                    page: 1
                    pageSize: 50
                    hasMore: false
                    total: 3
                    totalCapped: false
                    hits:
                      - hostname: acme-com-preview.vercel.app
                        provider: vercel
                        status: 0
                        title: ''
                        firstSeen: '2026-10-12T03:22:48Z'
                        lastSeen: '2026-10-28T19:10:05Z'
                        scanned: false
                        hasVerifiedCreds: false
                      - hostname: acme-com-staging.vercel.app
                        provider: vercel
                        status: 401
                        title: Acme (staging)
                        firstSeen: '2026-09-17T22:05:19Z'
                        lastSeen: '2026-10-29T08:41:02Z'
                        scanned: true
                        emails: []
                        hasVerifiedCreds: false
                        verifiedDetectors: []
                      - hostname: acme-dashboard.vercel.app
                        provider: vercel
                        status: 200
                        title: Acme dashboard
                        firstSeen: '2026-08-02T10:15:40Z'
                        lastSeen: '2026-10-30T14: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-Credential=…&X-Amz-Date=20261105T091500Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&x-id=GetObject&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-Credential=…&X-Amz-Date=20261105T091500Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&x-id=GetObject&X-Amz-Signature=…
                        emails:
                          - ops@acme.com
                        hasVerifiedCreds: true
                        verifiedDetectors:
                          - Stripe
                dropped:
                  summary: A q that parses to nothing, like status:2oo (not counted)
                  value:
                    query: ''
                    page: 1
                    pageSize: 50
                    hasMore: false
                    total: 0
                    totalCapped: false
                    hits: []
        '400':
          $ref: '#/components/responses/QueryRejected'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/SearchForbidden'
        '405':
          $ref: '#/components/responses/SearchMethodNotAllowed'
        '429':
          $ref: '#/components/responses/SearchLimited'
        '502':
          $ref: '#/components/responses/Unavailable'
        '503':
          $ref: '#/components/responses/SearchBusy'
        '504':
          $ref: '#/components/responses/Timeout'
components:
  headers:
    CacheControl:
      description: Always `no-store`, on every response.
      schema:
        type: string
        const: no-store
    WWWAuthenticate:
      description: >-
        The Bearer challenge: `Bearer realm="api.periphery.exposed"`, plus
        `error="invalid_token"` when the key is unknown or rotated.
      schema:
        type: string
    RetryAfter:
      description: >-
        Seconds to wait before retrying: 1 after a rate limit, 2 when busy, 5
        when unavailable, 3600 during maintenance. Not sent with the monthly
        limit (it resets on the 1st, UTC) or a timeout (a retry within a minute
        answers the same `504`).
      schema:
        type: integer
  schemas:
    SearchResponse:
      type: object
      required:
        - query
        - page
        - pageSize
        - hasMore
        - total
        - totalCapped
        - hits
      properties:
        query:
          type: string
          description: >-
            The canonical form of the query Periphery ran. Filter values it
            could not read are dropped or searched as text, so this shows what
            actually ran. `""` when `q` parsed to nothing (a missing or blank
            `q` is answered `400 query_required` instead).
        page:
          type: integer
          description: The page returned, starting at 1.
        pageSize:
          type: integer
          description: Hits per page, 50.
        hasMore:
          type: boolean
          description: >-
            True when the next page holds at least one hit. Exact; always false
            on page 20000. Stop paging when it is false.
        total:
          type: integer
          description: >-
            Number of apps matching the query, across all pages, from a count
            cached up to 10 minutes and raised to at least what this page
            proves. Counting stops at 1,000,000; see `totalCapped`.
        totalCapped:
          type: boolean
          description: >-
            True when counting stopped at 1,000,000, so at least `total` apps
            match.
        hits:
          type: array
          items:
            $ref: '#/components/schemas/AppSummary'
          description: Up to 50 hits for this page; `[]` when none.
    AppSummary:
      type: object
      description: >-
        One app, as a search hit returns it, and the first part of every app
        record. Strings, numbers and booleans are always present; timestamps are
        omitted when the event never happened; lists are `[]` when Periphery
        looked and found nothing and omitted when it never looked; signed URLs
        and `faviconHash` are omitted when absent. No field is ever `null`.
      required:
        - hostname
        - provider
        - status
        - title
        - lastSeen
        - scanned
        - hasVerifiedCreds
      properties:
        hostname:
          type: string
          description: The app's hostname, lower-case ASCII, its stable identifier.
          example: acme-dashboard.vercel.app
        provider:
          type: string
          description: >-
            Provider slug, the value the `provider:` filter takes, such as
            `vercel`, `netlify`, `railway` or `cloudflare_pages`. An open enum:
            new providers are added over time, see the providers page.
          example: vercel
        status:
          type: integer
          description: >-
            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.
          example: 200
        title:
          type: string
          description: >-
            Page title at the last capture; `""` when the page had none or was
            never rendered.
        firstSeen:
          type: string
          format: date-time
          description: >-
            Time of the first visit that got any answer from the host, RFC 3339
            in UTC. It never changes once set. For apps indexed before October
            2026, the time Periphery indexed them. Omitted when the host has
            never answered.
        lastSeen:
          type: string
          format: date-time
          description: >-
            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; always present.
        scanned:
          type: boolean
          description: >-
            True when Periphery captured the page and stored a snapshot (text,
            emails and credential scans). False for a host it only probed for
            liveness: then `emails`, `verifiedDetectors` and the snapshot URLs
            are absent.
        faviconHash:
          type: integer
          format: int32
          description: >-
            Shodan-compatible favicon hash (mmh3). Present whenever a favicon
            was stored, even when the hash is 0; omitted otherwise.
        faviconUrl:
          type: string
          format: uri
          description: >-
            Signed URL of the stored favicon, valid for one hour; `GET
            /v1/apps/{hostname}` returns a fresh one without using a search. An
            SVG favicon downloads as an attachment when opened directly. Omitted
            when no favicon was stored.
        screenshotUrl:
          type: string
          format: uri
          description: >-
            Signed URL of the stored screenshot, valid for one hour; `GET
            /v1/apps/{hostname}` returns a fresh one without using a search. It
            shows the page as captured and can show a secret in full. Omitted
            when there is no screenshot.
        emails:
          type: array
          items:
            type: string
          description: >-
            Email addresses found on the page, lower-cased and deduplicated, up
            to 100. `[]` when the page was scanned and had none; omitted when it
            was never scanned.
        hasVerifiedCreds:
          type: boolean
          description: >-
            True when at least one credential finding was verified, so
            `verifiedDetectors` is non-empty.
        verifiedDetectors:
          type: array
          items:
            type: string
          description: >-
            Names of the detectors with a verified finding, deduplicated
            case-insensitively and sorted. Each works as `creds:<name>`. `[]`
            when the credential scan found nothing verified; omitted when no
            credential scan was stored.
    Error:
      type: object
      required:
        - error
        - code
      properties:
        error:
          type: string
          description: Human-readable reason. It may change; switch on `code`.
        code:
          type: string
          description: >-
            Stable machine-readable reason. New codes may be added: treat an
            unknown code by its HTTP status.
          enum:
            - query_required
            - query_too_long
            - too_many_terms
            - page_out_of_range
            - api_key_missing
            - api_key_invalid
            - plan_required
            - https_required
            - app_not_found
            - not_found
            - method_not_allowed
            - ip_rate_limited
            - rate_limited
            - monthly_limit_reached
            - unavailable
            - busy
            - maintenance
            - timeout
  responses:
    QueryRejected:
      description: >-
        `q` is missing or blank, the query is over 256 characters or 10 terms,
        or `page` is past the last page. Not counted.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            queryRequired:
              summary: Missing or blank q
              value:
                error: 'q is required: give a search query'
                code: query_required
            queryTooLong:
              summary: Over 256 characters
              value:
                error: 'query too long: up to 256 characters'
                code: query_too_long
            tooManyTerms:
              summary: Over 10 terms
              value:
                error: 'query too long: up to 10 terms (this one has 12)'
                code: too_many_terms
            pageOutOfRange:
              summary: page past 20000
              value:
                error: page is past the last page (20000)
                code: page_out_of_range
    Unauthorized:
      description: Missing, unknown or rotated API key. Not counted.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        WWW-Authenticate:
          $ref: '#/components/headers/WWWAuthenticate'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            missing:
              summary: No key header
              value:
                error: missing API key
                code: api_key_missing
            invalid:
              summary: Unknown or rotated key
              value:
                error: invalid API key
                code: api_key_invalid
    SearchForbidden:
      description: >-
        The account is on the Free plan, which has no API access; or the request
        came over plain HTTP, in which case the key travelled in clear text and
        should be rotated. A `has:creds` query also needs the Plus plan, which
        every account with API access has. Not counted.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            planRequired:
              summary: Free plan
              value:
                error: API access requires the Plus plan
                code: plan_required
            credsPlanRequired:
              summary: has:creds without Plus (unreachable while the API is Plus-only)
              value:
                error: the has:creds filter requires the Plus plan
                code: plan_required
            httpsRequired:
              summary: Plain HTTP
              value:
                error: >-
                  HTTPS required: use https://api.periphery.exposed. A key sent
                  over plain HTTP travelled in clear text: rotate it.
                code: https_required
    SearchMethodNotAllowed:
      description: >-
        A method other than `GET`, `HEAD` included. Checked before the key;
        never counted.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Allow:
          description: '`GET`.'
          schema:
            type: string
            const: GET
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: method not allowed
            code: method_not_allowed
    SearchLimited:
      description: |
        Too many requests from this address (`ip_rate_limited`) or this
        account (`rate_limited`, one per second): retry after `Retry-After`.
        Or the account's monthly searches are used up
        (`monthly_limit_reached`): stop until the allowance resets on the
        1st, 00:00 UTC; no `Retry-After` is sent. Not counted.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            ipRate:
              summary: Over ten requests per second from this address
              value:
                error: 'rate limit: too many requests from this address'
                code: ip_rate_limited
            rate:
              summary: Over one request per second from this account
              value:
                error: 'rate limit: 1 request per second'
                code: rate_limited
            monthly:
              summary: Monthly searches used up
              value:
                error: monthly search limit reached
                code: monthly_limit_reached
    Unavailable:
      description: >-
        Periphery could not answer: a database or search backend failure, or the
        service is down. The result is unknown, not empty. Retry after
        `Retry-After`. A search answered this after the quota check counts.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: Periphery is temporarily unavailable, try again in a moment
            code: unavailable
    SearchBusy:
      description: >-
        `busy`: too many searches are running across Periphery (counts as a
        search). `maintenance`: Periphery is down for maintenance. Retry after
        `Retry-After`.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            busy:
              summary: Busy
              value:
                error: Periphery is busy, try again in a moment
                code: busy
            maintenance:
              summary: Maintenance
              value:
                error: >-
                  Periphery is temporarily unavailable for maintenance, try
                  again later
                code: maintenance
    Timeout:
      description: >-
        The search ran past its time limit. Narrow the query with more words or
        a filter rather than retrying it as is: a retry within a minute answers
        the same `504` and counts as a search. No `Retry-After`.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: Search took too long, narrow it with more words or a filter
            code: timeout
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: '`Authorization: Bearer <key>`. Used when both headers are present.'
    apiKeyHeader:
      type: apiKey
      in: header
      name: X-API-Key
      description: The same key in an `X-API-Key` header.

````

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