API keys

A key lets a script or a program search OwlyScan like your AI does.

Create a key

In the app at app.owlyscan.com, use the Connect your AI link on the search screen. It opens the page API keys and AI connection. Create your key there.

  • It starts with owly_.
  • It is shown once, when you create it. Copy it then.
  • Keep it secret: anyone who holds it searches on your quota.
  • You can revoke it on the same page.

Two ways in

An Access key works on the search route, on the two routes that reopen a past search, and on the MCP endpoint. Use the routes from a script or a program. Use the MCP endpoint from an AI app: see connect your AI for the URL and the two tools.

Send the key

Send the key in the X-API-Key header, or as a bearer token in Authorization. A missing or invalid key answers 401.

Headers
X-API-Key: owly_YOUR_KEY

# or, the same key as a bearer token:
Authorization: Bearer owly_YOUR_KEY

Every other route needs the web session of the app. A key does not open them.

Send a POST with a JSON body to /api/search on https://backend.owlyscan.com.

Search with curl
curl -X POST https://backend.owlyscan.com/api/search \
  -H "X-API-Key: owly_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": "acme.example"}'
  • query: your search, written as in the app. Search syntax. Only the first 20 words are used.
  • start_offset: optional, to page through the results. The default is 0. From 500 on, the answer is a 400 AccessLimit: see the cap below.
  • max_hits: optional, the size of a page. If you leave it out, you get 200. A page is clipped to what is left under the cap, which is 500 minus start_offset, and never refused. Results are grouped, so a page can hold fewer hits than max_hits.

The 500-result cap

  • Access shows at most 500 results of a search. darknet.num_hits stays the real total.
  • A start_offset of 500 or more answers 400 with {"error":"AccessLimit","limit":500}, and nothing is counted.

What comes back

Excerpts only: no full document, no web address and no file name. Read the darknet section and ignore the other sections and the other fields. Here is a shortened answer.

A shortened answer
{
  "darknet": {
    "num_hits": 12,
    "hits": [
      {
        "level0": "ransomware",
        "date": "2025-03-14",
        "entry": {
          "visibility": "deidentified",
          "segments": [
            {"t": "text", "text": "Invoice sent to "},
            {"t": "label", "kind": "person", "n": 1},
            {"t": "text", "text": " at "},
            {"t": "label", "kind": "email", "n": 1},
            {"t": "text", "text": " for "},
            {"t": "term", "text": "acme.example"},
            {"t": "hidden", "w": 3}
          ]
        }
      }
    ],
    "entry": {
      "yours_on_page": 0,
      "likely_on_page": 0,
      "search_id": "0b7c2e4a-5f1d-4c3e-9a8b-1d2e3f4a5b6c",
      "shown_limit": 500,
      "preparing": 0
    }
  }
}
  • darknet.num_hits: the total in the index for your query, not the number of hits in the page.
  • darknet.hits: the page. Each hit has level0, the channel (for example ransomware), and date, as YYYY-MM-DD or an empty string.
  • entry.visibility says what you may read. yours is a match of your company, readable. deidentified is a match of another company, with names replaced by labels. fields is a match of another company with no link to yours found: field names and your search terms, with at most one ordinary word on each side, are visible; every other value is hidden. hidden is masked: only your search terms are visible. likely is a match that probably concerns you, without certainty, or that a search for a brand linked to your company finds: organisation, brand and domain names are text, every other name or number stays a label, and passwords and hashes are secret segments. Treat any value you do not know as hidden.
  • entry.segments is the excerpt in pieces. Join them to rebuild it: text is plain text, term is one of your search terms, label is a masked person, email address or other identifier, numbered within the page (kind and n), and hidden is a masked stretch (w is only a display width).
  • secret is a masked password or hash in your own records. A password has prefix, at most its first two characters (an empty string when none are shown), and length. A hash has hash, its type such as MD5 or null when unknown, and length, and no prefix.
  • Treat any segment type you do not know as hidden.
  • darknet.entry.yours_on_page: optional, how many of your company’s matches are in this page.
  • darknet.entry.likely_on_page: the number of likely results on this page, 0 when there are none.
  • darknet.entry.search_id: the id of this search, a string. Use it to reopen the search for free: see below.
  • darknet.entry.shown_limit: how many results Access can show for a search, 500.
  • darknet.entry.preparing: how many hits of this page are still being prepared.
  • entry.hidden: set on a hit whose excerpt is not shown. preparing means the de-identified excerpt is being prepared. restricted means it is not shown in Access, and it will not change.
  • Every hit also has id and text: ignore them.

Hits being prepared

While darknet.entry.preparing is above 0, ask for the same page again through /page, which is free: see below. Never poll through /api/search. Every 5 seconds is enough. Stop when preparing reaches 0, or after about 2 minutes without a change.

Your own records are shown as they are, the others with names hidden, as in the app. In your records, passwords and hashes are masked: only their length and at most their first two characters show. Privacy and data.

Quota

A key shares the weekly counter with the app and your AI. The same query within 20 minutes of its first search is one search of your 100 a week, whatever the start_offset, and that search is shared with the app and your AI. After 20 minutes, the same query counts again. Reopening a past search never counts. Limits and quota.

Reopen a past search

Two routes reopen the searches you made in the last 90 days. Neither counts as a search.

  • GET /api/entry/searches lists your own searches, newest first, at most 100, none older than 90 days, with no paging. The answer is {"searches":[{"id","query","created_at","total","person_query"}],"retention_days":90}.
  • POST /api/entry/searches/SEARCH_ID/page reopens one of them, with id from the list or from darknet.entry.search_id. The body is optional: start_offset (default 0) and max_hits (default 50), with the same 500 cap.
Reopen a search with curl
curl -X POST https://backend.owlyscan.com/api/entry/searches/0b7c2e4a-5f1d-4c3e-9a8b-1d2e3f4a5b6c/page \
  -H "X-API-Key: owly_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"start_offset": 0, "max_hits": 50}'

The answer has the same shape as a search answer, plus darknet.entry.searched_at, in ISO format, UTC. Results are as of that moment: nothing indexed later appears. To see newer records, search again, which counts once the 20 minutes have passed.

Errors

  • 401: the key is missing or invalid.
  • 400: the query filters on a field other than the text, the date or level0. It costs nothing.
  • 400 AccessLimit with limit: start_offset is 500 or more. Nothing is counted.
  • 402: the subscription is not active.
  • 404 SearchNotFound, on /page: the search is unknown, is not yours or is older than 90 days.
  • 429 with resets_at: the weekly limit is reached. The answer gives resets_at (UTC), used and limit, and a Retry-After header in seconds.
  • 429 without resets_at: too many requests in a short time, on any route. Wait a moment and try again.
  • 503: temporary. Retry after a few seconds.

Connected AI apps

The same page lists the AI apps you connected, such as Claude, under Connected AI apps. Disconnect takes effect immediately.