API documentation
Classify URLs, search your scan history, and pull threat-intel feeds over HTTP / JSON.
Getting started
The ZerophishEx API is a JSON-over-HTTPS API rooted at
http://zerophish.ai/api. All responses are
application/json. The scanning endpoints accept anonymous
requests (heavily IP rate-limited); everything else needs an API key.
The machine-readable contract lives at
/api/openapi.json (OpenAPI 3.1) —
import it into Postman, Insomnia, Swagger UI, or generate a client with
openapi-generator.
Quick start (Python)
import requests, time
API_KEY = "zp_live_…"
H = {"Authorization": f"Bearer {API_KEY}"}
# 1. submit a URL
r = requests.post("http://zerophish.ai/api/scan", headers=H, json={"url": "https://example.com"})
data = r.json()
if data.get("cached"):
print("cached:", data["prediction"]["verdict"])
else:
job_id = data["job_id"]
# 2. poll until the scan completes
while True:
r = requests.get(f"http://zerophish.ai/api/scan/{job_id}", headers=H)
if r.status_code == 202:
time.sleep(2); continue
print(r.json())
break
Authentication
Mint an API key in workspace settings → API keys. Send it as a bearer token on every authenticated request:
Authorization: Bearer zp_live_xxxxxxxxxxxxxxxxxxxxxxxx
Keys carry one or more scopes:
| Scope | Grants |
|---|---|
| scan | Submit new URLs for scanning (POST /api/scan). |
| read | Read scan results, search, lookup and feeds. |
| admin | Reserved for future privileged endpoints. |
Organisation scoping is automatic. /api/search,
/api/lookup and /api/feeds/stix.json return data for
the workspace that owns the API key you send. Because a key is
minted inside a workspace, it is already
tied to that organisation — there is no org parameter to set. To
query a different organisation, use a key from that workspace.
A request that lacks the required scope returns
403 with body error: insufficient_scope. Anonymous calls to the
scanning endpoints are allowed but limited; /api/search,
/api/lookup and the STIX feed require a key.
API key for testing
Paste a key here to use the Try it buttons below. It is kept
only in your browser (this field + optional localStorage) and sent
directly to this origin — never logged or transmitted elsewhere.
/api/scan
optional · scope: scan
Submit a URL for scanning
Enqueues a phishing scan for a URL. By default a recent cached result is returned immediately if one exists; otherwise a background job is started and a `job_id` is returned that you poll with `GET /api/scan/{id}`. Anonymous calls are allowed but rate-limited by IP; authenticated calls get a higher per-key/per-org quota.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| url | body | string | yes | The absolute URL to scan (http/https). |
| fresh | body | boolean | no | Skip the cache and force a new scan. Accepts true/1/"true". |
| deep | body | boolean | no | Run a deep scan (headless browser, HAR, cert + network depth). Lower per-org quota. Alias: deep_scan. |
Code samples
curl -X POST http://zerophish.ai/api/scan \
-H "Authorization: Bearer $ZEROPHISH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"deep":false,"url":"https://example.com"}'
import requests
API_KEY = "zp_live_…" # from /app/settings/api-keys
resp = requests.post(
"http://zerophish.ai/api/scan",
headers={"Authorization": f"Bearer {API_KEY}"},
json={"deep": False, "url": "https://example.com"},
)
print(resp.status_code)
print(resp.json())
const API_KEY = "zp_live_…"; // from /app/settings/api-keys
const resp = await fetch("http://zerophish.ai/api/scan", {
method: "POST",
headers: {
"Authorization": `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"deep":false,"url":"https://example.com"}),
});
const data = await resp.json();
console.log(resp.status, data);
Responses
{
"cached": false,
"deep": false,
"job_id": 918273
}
{
"cached": true,
"prediction": {
"brands": "Example Bank",
"campaign_id": null,
"confidence": "high",
"duration_ms": 8421,
"id": "b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f",
"jarm": "27d40d40d29d40d1dc42d43d00041d...",
"kit_matches": [],
"normalized_url": "login.examplebank.com.verify-account.example/",
"phishing": true,
"phishing_score": 0.94,
"redirect_chain": [],
"scan_depth": "quick",
"screenshot_name": "b3b1f0e2.png",
"signals": [
{
"detail": "Brand appears as a subdomain label.",
"name": "brand_in_subdomain",
"severity": "high"
},
{
"detail": "Password field posts cross-origin.",
"name": "credential_form",
"severity": "high"
}
],
"similar_brands": [
{
"brand": "Example Bank",
"score": 0.91
}
],
"summary": "Credential-harvesting page impersonating Example Bank's login. The domain embeds the brand as a subdomain of an unrelated registrable domain.",
"suspicious_domain": true,
"tags": [
"credential-harvesting"
],
"url": "https://login.examplebank.com.verify-account.example/",
"url_parts": {
"host": "login.examplebank.com.verify-account.example",
"registrable_domain": "verify-account.example",
"scheme": "https"
},
"verdict": "phishing"
},
"scanned_at": "2026-05-30T12:01:55.000000Z"
}
{
"error": "url is required"
}
{
"error": "insufficient_scope",
"required": "scan"
}
{
"error": "Failed to start scan",
"reason": "..."
}
{
"error": "Daily request limit exceeded"
}
Try it
/api/scan/:id
optional · scope: read
Fetch scan result / status by job id
Returns the completed `Prediction` for a job id, or a status object while it is still running. Use the `job_id` returned by `POST /api/scan`. Returns the prediction object directly on success.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | integer | yes | The job id returned by POST /api/scan. |
Code samples
curl "http://zerophish.ai/api/scan/918273" \
-H "Authorization: Bearer $ZEROPHISH_API_KEY"
import requests
API_KEY = "zp_live_…" # from /app/settings/api-keys
resp = requests.get(
"http://zerophish.ai/api/scan/918273",
headers={"Authorization": f"Bearer {API_KEY}"},
)
print(resp.status_code)
print(resp.json())
const API_KEY = "zp_live_…"; // from /app/settings/api-keys
const resp = await fetch("http://zerophish.ai/api/scan/918273", {
headers: {
"Authorization": `Bearer ${API_KEY}`,
},
});
const data = await resp.json();
console.log(resp.status, data);
Responses
{
"brands": "Example Bank",
"campaign_id": null,
"confidence": "high",
"duration_ms": 8421,
"id": "b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f",
"jarm": "27d40d40d29d40d1dc42d43d00041d...",
"kit_matches": [],
"normalized_url": "login.examplebank.com.verify-account.example/",
"phishing": true,
"phishing_score": 0.94,
"redirect_chain": [],
"scan_depth": "quick",
"screenshot_name": "b3b1f0e2.png",
"signals": [
{
"detail": "Brand appears as a subdomain label.",
"name": "brand_in_subdomain",
"severity": "high"
},
{
"detail": "Password field posts cross-origin.",
"name": "credential_form",
"severity": "high"
}
],
"similar_brands": [
{
"brand": "Example Bank",
"score": 0.91
}
],
"summary": "Credential-harvesting page impersonating Example Bank's login. The domain embeds the brand as a subdomain of an unrelated registrable domain.",
"suspicious_domain": true,
"tags": [
"credential-harvesting"
],
"url": "https://login.examplebank.com.verify-account.example/",
"url_parts": {
"host": "login.examplebank.com.verify-account.example",
"registrable_domain": "verify-account.example",
"scheme": "https"
},
"verdict": "phishing"
}
{
"status": "in_progress"
}
{
"error": "Invalid job ID"
}
{
"error": "Job not found"
}
{
"error": "fetch failed: timeout"
}
Try it
/api/search
required · scope: read
Search your organisation's scans
Full-text + faceted search over the scans owned by the API key's organisation. The key is created inside a workspace, so it is already tied to that organisation — results are scoped to it automatically; there is no org parameter to set. Requires the `read` scope.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| q | query | string | no | Free-text query (matches URL, brand, summary). |
| verdict | query | string | no | Filter by verdict (e.g. phishing, safe, suspicious). |
| brand | query | string | no | Filter by detected brand. |
| tag | query | string | no | Filter by tag. |
| confidence | query | string | no | Filter by confidence (low/medium/high). |
| min_score | query | number | no | Minimum phishing_score (0.0–1.0). |
| max_score | query | number | no | Maximum phishing_score (0.0–1.0). |
| limit | query | integer | no | Max rows to return. |
Code samples
curl "http://zerophish.ai/api/search?q=examplebank" \
-H "Authorization: Bearer $ZEROPHISH_API_KEY"
import requests
API_KEY = "zp_live_…" # from /app/settings/api-keys
resp = requests.get(
"http://zerophish.ai/api/search",
headers={"Authorization": f"Bearer {API_KEY}"},
params={"q": "examplebank"},
)
print(resp.status_code)
print(resp.json())
const API_KEY = "zp_live_…"; // from /app/settings/api-keys
const resp = await fetch("http://zerophish.ai/api/search?q=examplebank", {
headers: {
"Authorization": `Bearer ${API_KEY}`,
},
});
const data = await resp.json();
console.log(resp.status, data);
Responses
{
"count": 1,
"scans": [
{
"brands": "Example Bank",
"campaign_id": null,
"confidence": "high",
"duration_ms": 8421,
"id": "b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f",
"jarm": "27d40d40d29d40d1dc42d43d00041d...",
"kit_matches": [],
"normalized_url": "login.examplebank.com.verify-account.example/",
"phishing": true,
"phishing_score": 0.94,
"redirect_chain": [],
"scan_depth": "quick",
"screenshot_name": "b3b1f0e2.png",
"signals": [
{
"detail": "Brand appears as a subdomain label.",
"name": "brand_in_subdomain",
"severity": "high"
},
{
"detail": "Password field posts cross-origin.",
"name": "credential_form",
"severity": "high"
}
],
"similar_brands": [
{
"brand": "Example Bank",
"score": 0.91
}
],
"summary": "Credential-harvesting page impersonating Example Bank's login. The domain embeds the brand as a subdomain of an unrelated registrable domain.",
"suspicious_domain": true,
"tags": [
"credential-harvesting"
],
"url": "https://login.examplebank.com.verify-account.example/",
"url_parts": {
"host": "login.examplebank.com.verify-account.example",
"registrable_domain": "verify-account.example",
"scheme": "https"
},
"verdict": "phishing"
}
]
}
{
"error": "invalid_query"
}
{
"error": "authentication_required"
}
Try it
/api/lookup
required · scope: read
Look up the latest known scan for a URL
Returns the most recent scan your organisation has for a normalised URL without starting a new scan. The organisation is taken from the API key automatically — no org parameter needed. Requires the `read` scope.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| url | query | string | yes | The URL to look up. |
Code samples
curl "http://zerophish.ai/api/lookup?url=https%3A%2F%2Fexample.com" \
-H "Authorization: Bearer $ZEROPHISH_API_KEY"
import requests
API_KEY = "zp_live_…" # from /app/settings/api-keys
resp = requests.get(
"http://zerophish.ai/api/lookup",
headers={"Authorization": f"Bearer {API_KEY}"},
params={"url": "https://example.com"},
)
print(resp.status_code)
print(resp.json())
const API_KEY = "zp_live_…"; // from /app/settings/api-keys
const resp = await fetch("http://zerophish.ai/api/lookup?url=https%3A%2F%2Fexample.com", {
headers: {
"Authorization": `Bearer ${API_KEY}`,
},
});
const data = await resp.json();
console.log(resp.status, data);
Responses
{
"cached": true,
"prediction": {
"brands": "Example Bank",
"campaign_id": null,
"confidence": "high",
"duration_ms": 8421,
"id": "b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f",
"jarm": "27d40d40d29d40d1dc42d43d00041d...",
"kit_matches": [],
"normalized_url": "login.examplebank.com.verify-account.example/",
"phishing": true,
"phishing_score": 0.94,
"redirect_chain": [],
"scan_depth": "quick",
"screenshot_name": "b3b1f0e2.png",
"signals": [
{
"detail": "Brand appears as a subdomain label.",
"name": "brand_in_subdomain",
"severity": "high"
},
{
"detail": "Password field posts cross-origin.",
"name": "credential_form",
"severity": "high"
}
],
"similar_brands": [
{
"brand": "Example Bank",
"score": 0.91
}
],
"summary": "Credential-harvesting page impersonating Example Bank's login. The domain embeds the brand as a subdomain of an unrelated registrable domain.",
"suspicious_domain": true,
"tags": [
"credential-harvesting"
],
"url": "https://login.examplebank.com.verify-account.example/",
"url_parts": {
"host": "login.examplebank.com.verify-account.example",
"registrable_domain": "verify-account.example",
"scheme": "https"
},
"verdict": "phishing"
},
"scanned_at": "2026-05-30T12:01:55.000000Z",
"status": "known"
}
{
"status": "unknown"
}
{
"error": "url_required"
}
{
"error": "authentication_required"
}
Try it
/api/public/lookup
no auth
Look up a publicly-visible scan for a URL (no auth)
Unauthenticated lookup against the corpus of public scans. IP rate-limited to 60/day. Does not start a scan. No API key required.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| url | query | string | yes | The URL to look up. |
Code samples
curl "http://zerophish.ai/api/public/lookup?url=https%3A%2F%2Fexample.com"
import requests
API_KEY = "zp_live_…" # from /app/settings/api-keys
resp = requests.get(
"http://zerophish.ai/api/public/lookup",
params={"url": "https://example.com"},
)
print(resp.status_code)
print(resp.json())
const API_KEY = "zp_live_…"; // from /app/settings/api-keys
const resp = await fetch("http://zerophish.ai/api/public/lookup?url=https%3A%2F%2Fexample.com", {
});
const data = await resp.json();
console.log(resp.status, data);
Responses
{
"cached": true,
"prediction": {
"brands": "Example Bank",
"campaign_id": null,
"confidence": "high",
"duration_ms": 8421,
"id": "b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f",
"jarm": "27d40d40d29d40d1dc42d43d00041d...",
"kit_matches": [],
"normalized_url": "login.examplebank.com.verify-account.example/",
"phishing": true,
"phishing_score": 0.94,
"redirect_chain": [],
"scan_depth": "quick",
"screenshot_name": "b3b1f0e2.png",
"signals": [
{
"detail": "Brand appears as a subdomain label.",
"name": "brand_in_subdomain",
"severity": "high"
},
{
"detail": "Password field posts cross-origin.",
"name": "credential_form",
"severity": "high"
}
],
"similar_brands": [
{
"brand": "Example Bank",
"score": 0.91
}
],
"summary": "Credential-harvesting page impersonating Example Bank's login. The domain embeds the brand as a subdomain of an unrelated registrable domain.",
"suspicious_domain": true,
"tags": [
"credential-harvesting"
],
"url": "https://login.examplebank.com.verify-account.example/",
"url_parts": {
"host": "login.examplebank.com.verify-account.example",
"registrable_domain": "verify-account.example",
"scheme": "https"
},
"verdict": "phishing"
},
"scanned_at": "2026-05-30T12:01:55.000000Z",
"status": "known"
}
{
"status": "unknown"
}
{
"error": "url_required"
}
{
"error": "rate_limited",
"limit": 60
}
Try it
/api/feeds/stix.json
required · scope: read
STIX 2.1 indicator feed of confirmed phishing
Exports your organisation's confirmed-phishing URLs from the last 90 days as a STIX 2.1 bundle of `indicator` objects — drop straight into a TIP. The organisation is taken from the API key automatically. Requires the `read` scope.
Code samples
curl "http://zerophish.ai/api/feeds/stix.json" \
-H "Authorization: Bearer $ZEROPHISH_API_KEY"
import requests
API_KEY = "zp_live_…" # from /app/settings/api-keys
resp = requests.get(
"http://zerophish.ai/api/feeds/stix.json",
headers={"Authorization": f"Bearer {API_KEY}"},
)
print(resp.status_code)
print(resp.json())
const API_KEY = "zp_live_…"; // from /app/settings/api-keys
const resp = await fetch("http://zerophish.ai/api/feeds/stix.json", {
headers: {
"Authorization": `Bearer ${API_KEY}`,
},
});
const data = await resp.json();
console.log(resp.status, data);
Responses
{
"id": "bundle--6f1d…",
"objects": [
{
"confidence": 90,
"id": "indicator--b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f",
"indicator_types": [
"malicious-activity"
],
"labels": [
"phishing"
],
"pattern": "[url:value = 'https://login.examplebank.com.verify-account.example/']",
"pattern_type": "stix",
"spec_version": "2.1",
"type": "indicator"
}
],
"type": "bundle"
}
{
"error": "authentication_required"
}