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

> Common Periphery API workflows: paginate search results, fetch full app records, refresh links, and automate safely around rate limits and errors.

These examples assume `PERIPHERY_API_KEY` is already set in your environment, and use `curl` and `jq`. See [Authentication](https://docs.periphery.exposed/api/authentication.md) for key creation and headers.

The one-liners pass `--retry 3 --retry-delay 2 --retry-max-time 30`, so curl retries a `429`, `502`, `503` or `504` a few times, waiting at least as long as `Retry-After` asks. A retried monthly `429` costs nothing, but a retried `504` uses another search each time, as can a retried `502` or `503` from a search; see [which requests use a search](https://docs.periphery.exposed/api/limits-and-errors.md#which-requests-use-a-search). The scripts use the helper below instead, which retries only what is worth retrying.

## A request helper for scripts

`api_get` sends one `GET` and prints the JSON body on success. It retries rate limits (`429 rate_limited` and `429 ip_rate_limited`), `502` and `503 busy`, up to five attempts, waiting for `Retry-After` or a doubling backoff, whichever is longer. It also retries when the connection fails. Anything else, such as a `504`, a `4xx` or the monthly `429`, fails at once and prints the JSON error to stderr, and so does a missing `PERIPHERY_API_KEY`. Save it next to your scripts:

```bash periphery-api.sh theme={null}
# api_get PATH [CURL_ARGS...]: GET an API path and print the JSON body.
api_get() {
  : "${PERIPHERY_API_KEY:?set PERIPHERY_API_KEY to your API key}"
  local path="$1" dir status code wait attempt
  shift
  dir="$(mktemp -d)"
  for attempt in 1 2 3 4 5; do
    status="$(curl -sS -o "$dir/body" -D "$dir/headers" -w '%{http_code}' --get \
      "https://api.periphery.exposed$path" \
      -H "Authorization: Bearer $PERIPHERY_API_KEY" "$@")" || status=000
    if [[ "$status" == 200 ]]; then
      cat "$dir/body"
      rm -rf "$dir"
      return 0
    fi
    code="$(jq -r '.code? // empty' "$dir/body" 2>/dev/null || true)"
    case "$status:$code" in
      000:* | 429:rate_limited | 429:ip_rate_limited | 502:* | 503:busy) ;;
      *)
        echo "GET $path: HTTP $status" >&2
        cat "$dir/body" >&2 2>/dev/null || true
        echo >&2
        rm -rf "$dir"
        return 1
        ;;
    esac
    if ((attempt == 5)); then
      break
    fi
    wait="$(tr -d '\r' 2>/dev/null <"$dir/headers" |
      awk -F': *' 'tolower($1) == "retry-after" { print $2 }' | tail -n 1)" || true
    if [[ ! "$wait" =~ ^[0-9]+$ ]] || ((wait < 2 ** (attempt - 1))); then
      wait=$((2 ** (attempt - 1)))
    fi
    sleep "$wait"
  done
  echo "GET $path: still failing after $attempt attempts (HTTP $status)" >&2
  cat "$dir/body" >&2 2>/dev/null || true
  echo >&2
  rm -rf "$dir"
  return 1
}
```

## Page through every result

Search returns 50 hits per page. This script prints every matching hostname once. It stops when `hasMore` is `false`, and waits between requests to stay within the one-request-per-second limit. Pages are offsets over live data, so an app can show up on two pages: the final `awk` keeps each hostname once.

```bash all-hosts.sh theme={null}
#!/usr/bin/env bash
set -euo pipefail
source "$(dirname "$0")/periphery-api.sh"

query='acme.com'
page=1

while :; do
  response="$(api_get /v1/apps --data-urlencode "q=$query" --data-urlencode "page=$page")"
  jq -r '.hits[].hostname' <<<"$response"
  if [[ "$(jq -r '.hasMore' <<<"$response")" != true ]]; then
    break
  fi
  page=$((page + 1))
  sleep 1
done | awk '!seen[$0]++'
```

Every page is one search against your [monthly allowance](https://docs.periphery.exposed/api/limits-and-errors.md#monthly-searches): a query with 40,000 matches takes 800 searches to read in full. Narrow the query first when you can.

## Fetch the full record for one hit

Search hits carry the snapshot links (favicon, screenshot), email addresses and the names of the detectors that verified a credential, but not the extracted text or the credential findings. Use the hostname as the stable identifier when you need those. Fetching a record uses no search.

```bash theme={null}
curl -fsS --retry 3 --retry-delay 2 --retry-max-time 30 \
  "https://api.periphery.exposed/v1/apps/acme-dashboard.vercel.app" \
  -H "Authorization: Bearer $PERIPHERY_API_KEY" |
  jq
```

A `404` with the code `app_not_found` means search cannot return that hostname: Periphery has never visited it, has marked it dead, or the hostname is not valid.

## Refresh expired links

`faviconUrl` and `screenshotUrl` expire an hour after the response that carried them. To download an artifact later, fetch the app record again for fresh links, rather than running the search again. This saves the screenshot of one app:

```bash save-screenshot.sh theme={null}
#!/usr/bin/env bash
set -euo pipefail

hostname='acme-dashboard.vercel.app'

url="$(
  curl -fsS --retry 3 --retry-delay 2 --retry-max-time 30 \
    "https://api.periphery.exposed/v1/apps/$hostname" \
    -H "Authorization: Bearer $PERIPHERY_API_KEY" |
    jq -r '.screenshotUrl // empty'
)"

if [[ -n "$url" ]]; then
  curl -fsS --retry 3 -o "$hostname.png" "$url"
else
  echo "$hostname: no screenshot" >&2
fi
```

Each record fetch shares the one-request-per-second limit, so space them a second apart when refreshing many apps.

## What's new for my org

`firstSeen:` keeps apps Periphery saw for the first time within the window, and search hits carry `firstSeen`. This script is meant to run once a day. Its window, two days, is twice the interval between runs, so a late or failed run misses nothing; the file of hostnames already reported keeps each app from being reported twice.

```bash whats-new.sh theme={null}
#!/usr/bin/env bash
set -euo pipefail
source "$(dirname "$0")/periphery-api.sh"

query='acme.com firstSeen:<2d'
seen="${PERIPHERY_SEEN_FILE:-$HOME/.periphery-seen}"
touch "$seen"

hits=''
page=1
while :; do
  response="$(api_get /v1/apps --data-urlencode "q=$query" --data-urlencode "page=$page")"
  hits+="$(jq -r '.hits[] | [.hostname, .firstSeen, .provider] | @tsv' <<<"$response")"$'\n'
  if [[ "$(jq -r '.hasMore' <<<"$response")" != true ]]; then
    break
  fi
  page=$((page + 1))
  sleep 1
done

# Keep each hostname once, and only those not reported by an earlier run.
new="$(printf '%s' "$hits" |
  awk -F'\t' 'FILENAME == ARGV[1] { seen[$1]; next } NF && !($1 in seen) && !dup[$1]++' "$seen" -)"

if [[ -n "$new" ]]; then
  printf '%s\n' "$new"
  printf '%s\n' "$new" | cut -f1 >>"$seen"
fi
```

Each run uses one search per page of results, usually one: about 30 searches a month when run daily.

## Find apps with verified credential findings

The `has:creds` filter keeps apps with at least one verified credential finding. `verifiedDetectors` names the detectors that verified one.

```bash theme={null}
curl -fsS --retry 3 --retry-delay 2 --retry-max-time 30 --get "https://api.periphery.exposed/v1/apps" \
  -H "Authorization: Bearer $PERIPHERY_API_KEY" \
  --data-urlencode "q=acme.com has:creds" |
  jq -r '.hits[] | [.hostname, .provider, (.verifiedDetectors // [] | join(","))] | @tsv'
```

Use the app endpoint only for records you actually need to inspect. Its `credentials` array lists each finding, masked.

## Keep credential-bearing responses out of logs

API responses can contain email addresses, masked credential values and, through `text` and `screenshotUrl`, what the page displayed when it was captured, including any secret it printed on screen. A signed link also works for anyone who holds it until it expires. Avoid dumping complete responses into CI logs, chat systems, tickets, analytics, or third-party AI tools unless your organisation has explicitly approved that data flow. The same applies to the [MCP server](https://docs.periphery.exposed/mcp/overview.md#what-leaves-periphery): what an assistant reads goes to its AI provider.

Select only the fields the next system needs. For example:

```bash theme={null}
curl -fsS --retry 3 --retry-delay 2 --retry-max-time 30 \
  "https://api.periphery.exposed/v1/apps/acme-dashboard.vercel.app" \
  -H "Authorization: Bearer $PERIPHERY_API_KEY" |
  jq '{hostname, provider, status, lastSeen, scanned, hasVerifiedCreds, verifiedDetectors} | with_entries(select(.value != null))'
```

This keeps `emails`, `credentials`, `text` and the signed links out of the output.

## Retry the errors that are retryable

Switch on the `code` in the error body:

* `rate_limited` and `ip_rate_limited` (`429`): wait for `Retry-After`, one second, then retry.
* `monthly_limit_reached` (`429`): stop searching until 00:00 UTC on the 1st. Retrying will not help.
* `unavailable` (`502`) and `busy` (`503`): retry after `Retry-After`, backing off if it keeps failing.
* `maintenance` (`503`): retry after `Retry-After`, an hour.
* `timeout` (`504`): narrow the query (more words or a filter) instead of retrying it unchanged. A retry within a minute returns the same `504` and uses another search.
* `query_required`, `query_too_long`, `too_many_terms` and `page_out_of_range` (`400`): fix the query or the page.
* `api_key_missing`, `api_key_invalid` (`401`) and `plan_required` (`403`): fix the key or plan instead of retrying.
* `https_required` (`403`): call `https://`, and rotate the key you sent over plain HTTP.
* `app_not_found` (`404`): treat the record as unavailable rather than repeatedly polling it.

Never treat an error as an empty result. See [Limits and errors](https://docs.periphery.exposed/api/limits-and-errors.md) for the complete error table.


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