---
url: https://ora.ai/docs/api/scanning
title: "Scanning API - Docs"
description: "Run agent-readiness scans and stream progress. Scans are rate-limited per IP and cached; cached responses never consume quota."
crawled_at: 2026-10-18T19:27:06Z
ask: https://ora-ai.ora.run/ask
docs: https://ora-ai.ora.run/.well-known/ora/docs
lang: en
---
# Scanning

Run agent-readiness scans and stream progress. Scans are rate-limited per IP and cached; cached responses never consume quota.

## POST /api/scan

#### Scan a domain, MCP server URL, or MCP App URL for agent-readiness

Runs a full agent-readiness scan on the given URL. Accepts a domain, MCP server URL, or MCP App URL (server that supports the MCP Apps extension `io.modelcontextprotocol/ui`) - the server auto-detects which kind of input was provided and selects the appropriate check set. Catalog-style listing pages are folded into the `mcp` kind by classifying the first validated embedded MCP URL. Returns score, grade, and detailed layer breakdown. The response includes an optional `urlKind` field indicating the detected kind ('domain', 'mcp', or 'mcp-app'). Scoring completes within the request, but deeper analysis can continue asynchronously afterwards: when the returned analysisStatus is 'partial', the response is a 202 Accepted with a Location header pointing at the polling endpoint for the remaining work; a 200 means analysis is already complete. For real-time progress updates, use GET /api/scan/stream which serves a text/event-stream. A complete stored result younger than the freshness window is returned as-is (servedFromCache, resultAgeSeconds, Age header) without running or persisting a scan - pass force: true, or widen/narrow maxAgeSeconds, to control that. Rate limited two ways: 10 requests per minute per IP (burst) and a durable daily scan budget shared with the other scan entry points - both return 429 with a Retry-After header.

##### PARAMETERS

competitors · string · query

Pass 1 to include a `competitors` object in the response: the category top-5 leaders plus the neighbor window (2 above / self / 2 below) drawn from the leaderboard. Returned only for domains with a market category - unclassified domains (Community, or no leaderboard row) get `competitors: null`. Omitted by default so the plain response stays lean.

format · string · query

Pass `audit` to receive the versioned, allowlisted audit shape (AuditScanResult / AuditScoreResult) instead of the default body: every field is documented, carries a `contractVersion`, and internal fields are dropped. Omit it and the response is unchanged from previous releases. On GET /api/scan/stream, only the `scan_complete` event's `result` is projected - all other events are identical to the default stream.

include · string · query

Opt-in expansion list (comma-separated). `essentials` adds one response key, `essentials`: an alternate reading of the same scan carrying its own `score` (0-100 or null - required checks share 80 points, recommended 20, forward-looking signals upside-only), the required/recommended buckets, label copy, per-surface sub-scores, access signals, and a `checks` map keyed by check id. That map holds the essentials INTERPRETATION only (tier, bonus, fraction, occurrences, `essentialsGain`) - name, status, details, ora's recommendation, and `estScoreGain` for the same id stay in `layers[].checks[]`, so nothing serializes twice; join on the id. The pre-sorted `issues` list and `scoreEvidence` are arrays of ids resolving in that map. Omit the parameter and the response is byte-identical to previous releases. On GET /api/checks, `essentials` instead adds `essentialsTier`, `essentialsBonusOnly`, and `essentialsExcluded` to every catalog check (an excluded check never enters the essentials model; since 1.19.1 that is the two robots.txt policy checks). Experimental: the audit schemas declare `siteType` only as a loose object, so its inner fields may change without a version bump, and pinning the contract major does not freeze them. `siteType` adds one response key, `siteType`: the same scan scored against what the site is (`content`, `business`, `app`, or `store`). It carries `type`, `source` (`declared`, `self-declared`, `classified`, `default`, `blocked`, or `unavailable`), `confidence` (`high` or `low`), `basis`, `score` (0-100 or null), per-layer `layers` (`id`, `score`, `maxScore`), per-check `states` (`req`, `bonus`, `gated`, or `emerging`), and `gatedOut` (each excluded check id with the gate that excluded it). A site-type score uses a different rubric from the canonical `score` and can land above or below it: never compare the two, only another `siteType.score` of the same `type`. When no type resolves, the key is `{ type: null, source: "unavailable", confidence: "low", score: null, layers: [] }` with a `basis` saying why.

##### REQUEST BODY required

url · string · required

The domain, MCP server URL, or MCP app URL to scan. The server detects which kind of input was provided and runs the appropriate check set. e.g. stripe.com

mcpUrl

string

Optional MCP server URL to inspect as the sole MCP target. A failed endpoint is never replaced by a discovered server

maxAgeSeconds

integer

How stale a stored result may be and still be returned instead of running a new scan. Defaults to 21600 (6 hours) and is clamped server-side to [3600, 86400] rather than rejected. See the 200 response's `servedFromCache` / `resultAgeSeconds` fields. e.g. 21600

force

boolean

Always run a live scan, whatever the age of the stored result. This is the only way to bypass the freshness window entirely.

ephemeral

boolean

Store the result as disposable: it is excluded from the leaderboard, the sites-scanned coverage count, research statistics, and score history, is served with Cache-Control: no-store, and is deleted after a few days. Intended for a local site exposed through a tunnel, or any host that will not exist tomorrow. Public tunnel hostnames (trycloudflare.com, ngrok, and similar) are stored this way whether or not the flag is set. Rejected with 400 EPHEMERAL_CLOBBER when the domain already has a real stored scan, since a disposable result would replace it.

siteType

string

Optional. With `?include=siteType`, scores the response's `siteType` reading against this type instead of the inferred one. Absent, the type is inferred. content | business | app | store

activeProbes

boolean

Optional, defaults to false. A few checks confirm that an endpoint the site declares for agents enforces what it declares by sending it a request that must be refused (for example an unauthenticated POST). Pass true to send those requests for this scan; without it the affected checks reuse the domain's previous result where one exists.

##### RESPONSES

200 Scan completed successfully and analysis is complete. With `?competitors=1`, also carries a `competitors` object (category leaders + neighbor window drawn from the leaderboard). With `?format=audit` the body is `#/components/schemas/AuditScanResult` instead of the shape below. A response served from the freshness window instead of a new scan additionally carries `servedFromCache: true` and `resultAgeSeconds` (on both body shapes) plus an `Age` header, and consumed no scan. -> ScanResult

202 Scan accepted and scored, but analysis is still in progress (analysisStatus is 'partial'). The body is the same shape as a 200 (including `competitors` when `?competitors=1` was passed, and `#/components/schemas/AuditScanResult` when `?format=audit` was passed). Poll the Location header URL (GET /api/score/{domain}) until analysisStatus is 'complete' and pendingChecks is empty. -> ScanResult

400 Invalid input, or `ephemeral: true` for a domain that already has a real stored scan (body carries `code: "EPHEMERAL_CLOBBER"`; storing a disposable result would replace the real one). -> ErrorResponse

422 MCP authentication is required. No score or grade was produced. The failed attempt does not overwrite a previous measured scan. -> McpAuthRequiredResponse

429 Rate limit exceeded - either the 10-per-minute burst cap or the durable daily quota (30 scans per rolling 24h per IP; 6 per day for force=true). Cache-served responses never count against the daily quota. The response includes a Retry-After header indicating seconds until the next request is allowed, and a JSON body with error and retry_after_ms. -> ErrorResponse

500 Scan failed

###### GET /api/scan/stream

#### Stream an agent-readiness scan as Server-Sent Events

Runs a full agent-readiness scan on the given URL and streams progress as text/event-stream. Accepts a domain, MCP server URL, or MCP App URL (server that supports the MCP Apps extension `io.modelcontextprotocol/ui`) - the server auto-detects which kind of input was provided and selects the appropriate check set. Catalog-style listing pages are folded into the `mcp` kind by classifying the first validated embedded MCP URL. The stream emits a `kind_detecting` event immediately after the cheap reachability probe, followed by exactly one `kind_detected` event with payload `{ kind: 'domain' | 'mcp' | 'mcp-app', mcpUrl?: string, embeddedMcpUrls?: string[], hint?: string }` once URL-kind detection resolves. Progress events follow (the 200 response lists every event type), and finally `scan_complete` whose payload mirrors the ScanResult schema (including the optional `urlKind` field indicating the detected kind). The same freshness gate as POST /api/scan applies: a hit is a `kind_detected` frame followed by the terminal `scan_complete` event, rather than a full run. Rate limited two ways: 10 requests per minute per IP (burst) and a durable daily scan budget shared with the other scan entry points - both return 429 with a Retry-After header.

##### PARAMETERS

domain · string · query · required

The domain, MCP server URL, or MCP app URL to scan. The server detects which kind of input was provided and runs the appropriate check set.

mcp · string · query

Optional MCP server URL to inspect as the sole MCP target; it is never replaced by a discovered server

maxAgeSeconds · integer · query

Same freshness window as POST /api/scan: how stale a stored result may be and still be streamed back instead of running a new scan. Defaults to 21600 (6 hours), clamped to [3600, 86400].

force · string · query

Pass 1 to always run a live scan, whatever the age of the stored result.

ephemeral · string · query

Pass 1 to store the result as disposable - the same flag POST /api/scan takes in its body, with the same 400 EPHEMERAL_CLOBBER refusal when the domain already has a real stored scan.

activeProbes · string · query

Pass 1 to send the requests a few checks send to endpoints the site declares for agents - the query spelling of `activeProbes: true` on POST /api/scan. Absent, they are not sent.

format · string · query

Pass `audit` to receive the versioned, allowlisted audit shape (AuditScanResult / AuditScoreResult) instead of the default body: every field is documented, carries a `contractVersion`, and internal fields are dropped. Omit it and the response is unchanged from previous releases. On GET /api/scan/stream, only the `scan_complete` event's `result` is projected - all other events are identical to the default stream.

include · string · query

Opt-in expansion list (comma-separated). Experimental: the audit schemas declare `siteType` only as a loose object, so its inner fields may change without a version bump, and pinning the contract major does not freeze them. `siteType` adds one response key, `siteType`: the same scan scored against what the site is (`content`, `business`, `app`, or `store`). It carries `type`, `source` (`declared`, `self-declared`, `classified`, `default`, `blocked`, or `unavailable`), `confidence` (`high` or `low`), `basis`, `score` (0-100 or null), per-layer `layers` (`id`, `score`, `maxScore`), per-check `states` (`req`, `bonus`, `gated`, or `emerging`), and `gatedOut` (each excluded check id with the gate that excluded it). A site-type score uses a different rubric from the canonical `score` and can land above or below it: never compare the two, only another `siteType.score` of the same `type`. When no type resolves, the key is `{ type: null, source: "unavailable", confidence: "low", score: null, layers: [] }` with a `basis` saying why. On the stream the key rides the `scan_complete` event beside `result`, never inside it.

siteType · string · query

Read-time lens for `include=siteType`: scores the scan against this type instead of the resolved one, without rescanning or storing anything. An unrecognized value is ignored, not rejected.

##### RESPONSES

200 Server-Sent Events stream of scan progress. If MCP authentication is required, the stream ends with an error event carrying code MCP_AUTH_REQUIRED, mcpAuthRequired: true, mcpUrl and urlKind, without scan_complete or a score. With `?format=audit`, the `scan_complete` event's `result` is `#/components/schemas/AuditScanResult`; every other event is unchanged. When a stored result inside the freshness window answers the request, the stream is a `kind_detected` frame followed by a terminal `scan_complete` event carrying `servedFromCache: true` and `resultAgeSeconds`, and the response carries an `Age` header. After `scan_complete`, a live scan may emit two correction events before the stream closes, both optional: `relevance_assessed` (`{ naCheckIds, reasons, score, grade }`, only when some checks are judged not applicable for this product) carries the corrected `score` and `grade`, and `summary_ready` (`{ agenticSummary }`) carries the summary. Consume events until the stream closes. Closing does not mean the analysis is final: when deep checks are still pending, the stream closes early and a background worker finishes relevance and summary later. Read GET /api/score/{domain} after the stream closes and poll it while `analysisStatus` is `partial`. A freshness-window hit is already final and emits neither event. Event types (the `type` field), in emission order: `kind_detecting` - reachability passed and URL-kind detection is running (no payload); `kind_detected` - detection resolved: `{ kind: 'domain' | 'mcp' | 'mcp-app', mcpUrl?, embeddedMcpUrls?, hint? }`, exactly once, before any `check_start`; `discovery_phase` (optional) - a context-gathering step, or after `scan_complete` a post-scan finalize step: `{ step, label?, stepIndex?, totalSteps? }`, zero or more times; `scan_init` - the check plan: `{ layerMaxScores, totalChecks?, checkRoster?, staticOnly? }`; may be sent twice, the later one is authoritative; `check_start` - one check slot started: `{ layerId, layerName, checkId, checkName, mcpKind?, mcpUrl? }`; `check_complete` - one check slot finished: `{ layerId, layerName, checkId, checkName, status, score, maxScore, details?, bonus?, maturity?, mcpKind?, mcpUrl? }`; `layer_complete` - every check in a layer finished: `{ layerId, layerName }`; `scan_complete` - the scored result, not the last frame: `{ result }` (AuditScanResult under `?format=audit`), plus `siteType` with `?include=siteType` and `servedFromCache` / `resultAgeSeconds` on a freshness hit; `relevance_assessed` (optional) - after `scan_complete`, only when checks were judged not applicable: `{ naCheckIds, reasons, score, grade }` with the corrected score and grade; `summary_ready` (optional) - after `scan_complete`: `{ agenticSummary }`; `error` (terminal, optional) - the scan failed and no `scan_complete` follows: `{ message }`, plus `code: 'MCP_AUTH_REQUIRED'`, `mcpAuthRequired`, `mcpUrl` and `urlKind` when an MCP server requires authentication. Read until the stream closes; only a terminal event is guaranteed to be the last frame.

400 Missing or invalid domain parameter

429 Rate limit exceeded - either the 10-per-minute burst cap or the durable daily quota (30 scans per rolling 24h per IP; 6 per day for force=1). The response carries a Retry-After header with the seconds until the caller's window frees up.

###### POST /api/scan/checks

#### Run a selected subset of checks against a URL

Runs only the checks you select against the given URL and returns per-check results - the re-verify step after shipping a fix, with check ids from GET /api/checks. The run always executes; results are never served from a cache, so a re-check reflects the fix you just deployed (allow for DNS and CDN caches clearing). For most or all of the catalog, use POST /api/scan instead: same budget unit, and it returns a score. The response carries no aggregate score; GET /api/score/{domain} is the score surface. Callers holding a scan API key also get stored-scan patching - see storedScanUpdated on the response. Rate limited two ways: 10 requests per minute per IP, and one run spends one unit of the daily scan budget shared with POST /api/scan, spent once the target has been probed and classified; requests rejected earlier (invalid input, unknown ids, unreachable) spend nothing. Scan API key callers are exempt from both - an exemption, not a larger allowance; keys are issued manually on request - contact ora.

##### REQUEST BODY required

url · string · required

The website, MCP server, or API URL to run checks against. A bare domain like example.com is accepted.

checkIds · array · required

Array of check ids from GET /api/checks - one id minimum, up to the catalogued check count. Duplicate ids are deduplicated; ids the catalog does not list are rejected with error code UNKNOWN_CHECK_IDS.

mcpUrl

string

Optional URL of the target's MCP server, matching the same field on POST /api/scan. It pins the MCP endpoint; a failed handshake never substitutes a discovered server. When omitted, ora auto-discovers MCP endpoints. An empty string is treated as absent.

##### RESPONSES

200 Per-check results for the selection - at least one entry per requested id, including 'na' entries for ids that cannot apply to the detected kind. Always synchronous and complete: there is no 202 and no pending status. -> RunChecksResponse

400 Invalid input - either a schema failure (`{ error, details }` with the Zod detail, whose checkIds bounds messages point at POST /api/scan for full runs) or an id the catalog does not list (`code: "UNKNOWN_CHECK_IDS"` with the offending ids echoed in `details` and GET /api/checks as the pointer), or an invalid / unreachable domain. -> ErrorResponse

429 Rate limit exceeded - either the 10-per-minute burst cap (body `{ error }`) or the durable daily scan budget shared with POST /api/scan (body also carries `retry_after_ms`). Both carry a Retry-After header with the seconds until the caller's window frees up. -> ErrorResponse

500 Selective run failed

# Footer

The standard for agent experience.

© 2026 [era labs](https://eralabs.ai/)

Product

- Scan a site

- [Journey](https://journey.ora.ai/)

- [WebMCP audit](https://webmcp.ora.ai/)

- [Directory](https://directory.ora.ai/)

Resources

- Methodology

- Research

- Blog

- API Docs

Company

- About

- Contact

- Privacy

- [@oradotai](https://x.com/oradotai)
