Skip to main content

Developer documentation

A streaming API for evidence-based scans.

The scan endpoint returns newline-delimited JSON progress events, persists the completed report, and provides its unguessable identifier for retrieval. All provider credentials stay server-side.

Quick start

Start the application, then submit a public URL. The request must use application/json. Anonymous scans are limited by a guest quota; signed-in users use their plan quota.

Start the scanner
npm install
npx prisma migrate dev
npm run dev
Submit a scan
curl -N -X POST http://localhost:3000/api/scan \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com"}'

Use curl -N to disable client buffering. The final event contains the report identifier; retrieve the full JSON from the report endpoint.

POST/api/scan

Validates the URL, applies rate and concurrency limits, creates a scan record, and starts the analysis pipeline. The response is an NDJSON stream with one JSON object per line.

Request body

FieldTypeRequiredDescription
urlstringYes1–4,096 characters. Bare hostnames are treated as HTTPS. Only public HTTP/HTTPS targets on ports 80 and 443 are accepted.

Rate and concurrency controls

The defaults are 5 scans per minute and 30 per hour per client, with up to 4 concurrent scans. Configure with SCAN_RATE_LIMIT_PER_MINUTE, SCAN_RATE_LIMIT_PER_HOUR, and MAX_CONCURRENT_SCANS. The limiter is process-local; use a shared store for multi-instance deployments.

By default, forwarded client-IP headers are not trusted. Set TRUST_PROXY=true only when a trusted reverse proxy overwrites them, and set RATE_LIMIT_SALT to a random value.

Accounts and authentication

The scan endpoint is anonymous by default. A signed-in session is optional and raises the daily limit according to the plan. Anonymous callers are limited by a guest quota and the per-IP rate limiter; signed-in callers use their plan quota.

PlanDaily scansHistory
Free2Last 5 scans
Pro100Last 100 scans
Developer500Last 200 scans

Daily quotas use UTC calendar days and reset at 00:00 UTC. Exceeding a quota returns 429 QUOTA_EXCEEDED with a Retry-After header.

Account, subscription, and billing endpoints require a signed-in session and a CSRF token (x-securvex-csrf). Full details are in the API reference.

Progress stream

Each line is a complete JSON object. Stage events describe progress; the final event is either complete or error.

Example stage event
{"type":"stage","stage":"reputation","label":"Querying threat-intelligence providers","status":"running","progress":18}
Example completion event
{"type":"complete","scanId":"V1StGXR8_Z5jdHi6B-myT","verdict":"Elevated risk","score":57,"progress":100}
Example stream error
{"type":"error","error":{"code":"SCAN_FAILED","message":"The scan could not be completed. No clean result was produced. Please try again."}}
FieldMeaning
typestage, complete, or error.
stagevalidate, reputation, domain, dns, content, scoring, or ai.
statusrunning, passed, warning, threat, unknown, or skipped.
progressA progress hint from 0 to 100; not a time estimate.
scanIdThe public report identifier on a complete event.

Progress events are intentionally not a security boundary. Do not treat a stage status as a verdict; use the deterministic verdict in the completed report.

GET/api/scan/{publicId}

Returns the stored JSON report. Reports are addressed by a random, unguessable identifier and expire according to the deployment policy. Failed, running, missing, or expired reports return 404.

Fetch a report
curl http://localhost:3000/api/scan/V1StGXR8_Z5jdHi6B-myT

The response is a complete report object with target, verdict, summary, evidence, providers, checks, scoring groups, limitations, and retention information. The HTML report is available at /scan/{publicId}.

GET/api/health

Reports database health and which capabilities are configured. It never returns API keys or secrets. A healthy service returns 200; a database failure returns 503 with status: "degraded".

Example response
{
  "status": "ok",
  "timestamp": "2026-10-19T12:00:00.000Z",
  "database": { "ok": true },
  "providers": [
    { "id": "google_safe_browsing", "name": "Google Safe Browsing", "configured": false },
    { "id": "virustotal", "name": "VirusTotal", "configured": false },
    { "id": "urlhaus", "name": "URLhaus (abuse.ch)", "configured": false },
    { "id": "phishunt", "name": "Phishunt community feed", "configured": true }
  ],
  "ai": { "provider": "none", "configured": false, "model": null }
}
POST/api/maintenance/retention

Deletes expired scan records. The endpoint is disabled unless CRON_SECRET is configured. Send the secret as a bearer token. Run this from a scheduler; the default retention period is 30 days.

Run cleanup
curl -X POST http://localhost:3000/api/maintenance/retention \
  -H "Authorization: Bearer $CRON_SECRET"

The response contains the number of deleted records. Do not expose the secret to the browser or commit it to source control.

Error codes

Normal API errors use a JSON object with an error code, message, and optionally a retry delay. A failure after the stream begins is delivered as an NDJSON error event.

CodeHTTPMeaning
INVALID_INPUT400The JSON body is missing a valid url string, or the URL exceeds the length limit.
INVALID_JSON400The request body is not valid JSON.
UNSUPPORTED_MEDIA_TYPE415The request Content-Type is not application/json.
PAYLOAD_TOO_LARGE413The request body exceeds the configured limit.
EMPTY_URL / MALFORMED_URL400The URL is empty or cannot be parsed.
URL_TOO_LONG400The URL is longer than the 4,096-character limit.
CONTROL_CHARACTERS400The URL contains control characters.
MISSING_HOST400The URL does not contain a hostname.
UNSUPPORTED_SCHEME / UNSUPPORTED_PORT400Only HTTP/HTTPS on ports 80 or 443 is accepted.
EMBEDDED_CREDENTIALS400The URL contains a username or password.
PRIVATE_TARGET400The target is local, private, reserved, or a metadata endpoint.
INVALID_URL400The URL failed validation for an unexpected reason.
RATE_LIMITED429Per-minute or per-hour scan limit reached. See Retry-After.
SCANNER_BUSY503The configured concurrent-scan limit is reached.
STORAGE_UNAVAILABLE503The report database is not reachable.
INTERNAL_ERROR500An unexpected server error occurred.
SCAN_FAILEDstreamA failure after the progress stream began. Delivered as an NDJSON error event.
NOT_FOUND404A report identifier is invalid, missing, failed, or expired.
SAMPLE_UNAVAILABLE403 / 503The live phishing sample endpoint is unavailable or disabled.
NOT_CONFIGURED / UNAUTHORIZED503 / 401The retention endpoint is disabled or the bearer token is missing or invalid.

The scan endpoint is intentionally same-origin and JSON-only. It does not implement per-user authentication; deploy it behind your own access controls if you need to restrict who can run scans.

Repository documentation

The repository includes docs/API.md, docs/SECURITY.md, docs/PRIVACY.md, and docs/DEPLOYMENT.md for operator and integration details.