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.
npm install
npx prisma migrate dev
npm run devcurl -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.
/api/scanValidates 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
| Field | Type | Required | Description |
|---|---|---|---|
| url | string | Yes | 1–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.
| Plan | Daily scans | History |
|---|---|---|
| Free | 2 | Last 5 scans |
| Pro | 100 | Last 100 scans |
| Developer | 500 | Last 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.
{"type":"stage","stage":"reputation","label":"Querying threat-intelligence providers","status":"running","progress":18}{"type":"complete","scanId":"V1StGXR8_Z5jdHi6B-myT","verdict":"Elevated risk","score":57,"progress":100}{"type":"error","error":{"code":"SCAN_FAILED","message":"The scan could not be completed. No clean result was produced. Please try again."}}| Field | Meaning |
|---|---|
| type | stage, complete, or error. |
| stage | validate, reputation, domain, dns, content, scoring, or ai. |
| status | running, passed, warning, threat, unknown, or skipped. |
| progress | A progress hint from 0 to 100; not a time estimate. |
| scanId | The 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.
/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.
curl http://localhost:3000/api/scan/V1StGXR8_Z5jdHi6B-myTThe 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}.
/api/healthReports 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".
{
"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 }
}/api/maintenance/retentionDeletes 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.
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.
| Code | HTTP | Meaning |
|---|---|---|
| INVALID_INPUT | 400 | The JSON body is missing a valid url string, or the URL exceeds the length limit. |
| INVALID_JSON | 400 | The request body is not valid JSON. |
| UNSUPPORTED_MEDIA_TYPE | 415 | The request Content-Type is not application/json. |
| PAYLOAD_TOO_LARGE | 413 | The request body exceeds the configured limit. |
| EMPTY_URL / MALFORMED_URL | 400 | The URL is empty or cannot be parsed. |
| URL_TOO_LONG | 400 | The URL is longer than the 4,096-character limit. |
| CONTROL_CHARACTERS | 400 | The URL contains control characters. |
| MISSING_HOST | 400 | The URL does not contain a hostname. |
| UNSUPPORTED_SCHEME / UNSUPPORTED_PORT | 400 | Only HTTP/HTTPS on ports 80 or 443 is accepted. |
| EMBEDDED_CREDENTIALS | 400 | The URL contains a username or password. |
| PRIVATE_TARGET | 400 | The target is local, private, reserved, or a metadata endpoint. |
| INVALID_URL | 400 | The URL failed validation for an unexpected reason. |
| RATE_LIMITED | 429 | Per-minute or per-hour scan limit reached. See Retry-After. |
| SCANNER_BUSY | 503 | The configured concurrent-scan limit is reached. |
| STORAGE_UNAVAILABLE | 503 | The report database is not reachable. |
| INTERNAL_ERROR | 500 | An unexpected server error occurred. |
| SCAN_FAILED | stream | A failure after the progress stream began. Delivered as an NDJSON error event. |
| NOT_FOUND | 404 | A report identifier is invalid, missing, failed, or expired. |
| SAMPLE_UNAVAILABLE | 403 / 503 | The live phishing sample endpoint is unavailable or disabled. |
| NOT_CONFIGURED / UNAUTHORIZED | 503 / 401 | The 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.