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. Responses carry `X-RateLimit-Limit` and `X-RateLimit-Remaining` headers. Non-http(s), private/internal, or over-length URLs are rejected with 400.
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,
"enrichment": {
"asn": 13335,
"asn_org": "Cloudflare, Inc.",
"caa_issuers": [],
"caa_present": false,
"cert_issuer": "C=US, O=Let's Encrypt, CN=R10",
"cert_not_before": "2026-06-23T00:00:00Z",
"country": "United States (US)",
"dmarc_policy": null,
"dnssec_signed": false,
"domain_age_days": 4,
"mx_present": true,
"registered_at": "2026-06-23T11:42:00Z",
"registrar": "NameSilo, LLC",
"spf_policy": "soft"
},
"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"
],
"threat_intel": {
"sources": [
"urlhaus",
"openphish"
]
},
"ttp_tags": [
{
"id": "T1583.001",
"name": "Acquire Infrastructure: Domains",
"tactic": "Resource Development",
"url": "https://attack.mitre.org/techniques/T1583/001/"
},
{
"id": "T1056.003",
"name": "Input Capture: Web Portal Capture",
"tactic": "Credential Access",
"url": "https://attack.mitre.org/techniques/T1056/003/"
}
],
"url": "https://login.examplebank.com.verify-account.example/",
"url_parts": {
"host": "login.examplebank.com.verify-account.example",
"hostname_skeleton": "login.examplebank.com.verify-account.example",
"registrable_domain": "verify-account.example",
"scheme": "https"
},
"verdict": "phishing",
"verdict_rationale": {
"contributing": [
{
"detail": "Real brand \"examplebank\" used as subdomain of attacker-owned root",
"effect": "increases",
"id": "subdomain_brand",
"label": "Brand-in-subdomain attack",
"status": "fail"
},
{
"detail": "Credential collection form detected on the page",
"effect": "increases",
"id": "credential_form",
"label": "Credential collection form",
"status": "fail"
},
{
"detail": "Served over HTTPS · valid TLS certificate",
"effect": "decreases",
"id": "ssl",
"label": "SSL certificate",
"status": "pass"
}
],
"enrichment_sources": [
"rdap",
"dns",
"asn"
],
"model": "gpt-5.4-nano"
}
},
"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/batch
required · scope: scan
Submit a named batch of URLs for scanning
Enqueues up to 50 URLs as a single named batch and returns a `batch_id`. Poll aggregate progress + results with one `GET /api/batches/{batch_id}` call instead of N individual `GET /api/scan/{id}`s. Each accepted URL still carries its own `job_id`. Unlike `POST /api/scan` this always starts a fresh scan (no cache short-circuit) and requires an authenticated key with the `scan` scope. Each URL counts against your per-key/per-org daily quota; URLs beyond the quota come back under `rejected` with `rate_limited`, and invalid/blank URLs come back as `invalid_url`. The URL list may be supplied as a JSON body `{"urls": [...]}`, a `text/csv` body (one URL per line, optional `url` header), or an `application/jsonl` body (one JSON string or `{"url": ...}` object per line).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| urls | body | array | yes | Array of absolute http/https URLs (1–50). Alternatively send a text/csv or application/jsonl body instead of JSON. |
Code samples
curl -X POST http://zerophish.ai/api/scan/batch \
-H "Authorization: Bearer $ZEROPHISH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"urls":["https://example.com","https://login.examplebank.com.verify.example/"]}'
import requests
API_KEY = "zp_live_…" # from /app/settings/api-keys
resp = requests.post(
"http://zerophish.ai/api/scan/batch",
headers={"Authorization": f"Bearer {API_KEY}"},
json={"urls": ["https://example.com", "https://login.examplebank.com.verify.example/"]},
)
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/batch", {
method: "POST",
headers: {
"Authorization": `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"urls":["https://example.com","https://login.examplebank.com.verify.example/"]}),
});
const data = await resp.json();
console.log(resp.status, data);
Responses
{
"accepted": [
{
"job_id": 918273,
"url": "https://example.com"
}
],
"batch_id": "b47ac10b-58cc-4372-a567-0e02b2c3d479",
"count": 1,
"rejected": [
{
"error": "invalid_url",
"url": "ht!tp://bad"
}
]
}
{
"error": "too many urls",
"max": 50
}
{
"error": "authentication_required"
}
Try it
/api/batches/:id
required · scope: read
Fetch aggregate batch progress + results
Returns aggregate progress for a batch created by `POST /api/scan/batch` — a `progress` tally of the per-URL job states (completed / failed / retrying / pending), a `done` flag, and a `results` array resolving each completed job to a compact prediction summary. One call replaces polling N job ids. Requires an authenticated key with the `read` scope; the batch is scoped to your organisation (another org's batch id returns 404).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Batch UUID returned by POST /api/scan/batch. |
Code samples
curl "http://zerophish.ai/api/batches/b47ac10b-58cc-4372-a567-0e02b2c3d479" \
-H "Authorization: Bearer $ZEROPHISH_API_KEY"
import requests
API_KEY = "zp_live_…" # from /app/settings/api-keys
resp = requests.get(
"http://zerophish.ai/api/batches/b47ac10b-58cc-4372-a567-0e02b2c3d479",
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/batches/b47ac10b-58cc-4372-a567-0e02b2c3d479", {
headers: {
"Authorization": `Bearer ${API_KEY}`,
},
});
const data = await resp.json();
console.log(resp.status, data);
Responses
{
"batch_id": "b47ac10b-58cc-4372-a567-0e02b2c3d479",
"done": false,
"progress": {
"completed": 1,
"failed": 0,
"pending": 1,
"retrying": 0
},
"results": [
{
"brands": "Example Bank",
"confidence": "high",
"id": "b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f",
"job_id": 918273,
"phishing_score": 0.97,
"url": "https://example.com",
"verdict": "phishing"
}
],
"total": 2
}
{
"error": "authentication_required"
}
{
"error": "Batch not found"
}
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,
"enrichment": {
"asn": 13335,
"asn_org": "Cloudflare, Inc.",
"caa_issuers": [],
"caa_present": false,
"cert_issuer": "C=US, O=Let's Encrypt, CN=R10",
"cert_not_before": "2026-06-23T00:00:00Z",
"country": "United States (US)",
"dmarc_policy": null,
"dnssec_signed": false,
"domain_age_days": 4,
"mx_present": true,
"registered_at": "2026-06-23T11:42:00Z",
"registrar": "NameSilo, LLC",
"spf_policy": "soft"
},
"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"
],
"threat_intel": {
"sources": [
"urlhaus",
"openphish"
]
},
"ttp_tags": [
{
"id": "T1583.001",
"name": "Acquire Infrastructure: Domains",
"tactic": "Resource Development",
"url": "https://attack.mitre.org/techniques/T1583/001/"
},
{
"id": "T1056.003",
"name": "Input Capture: Web Portal Capture",
"tactic": "Credential Access",
"url": "https://attack.mitre.org/techniques/T1056/003/"
}
],
"url": "https://login.examplebank.com.verify-account.example/",
"url_parts": {
"host": "login.examplebank.com.verify-account.example",
"hostname_skeleton": "login.examplebank.com.verify-account.example",
"registrable_domain": "verify-account.example",
"scheme": "https"
},
"verdict": "phishing",
"verdict_rationale": {
"contributing": [
{
"detail": "Real brand \"examplebank\" used as subdomain of attacker-owned root",
"effect": "increases",
"id": "subdomain_brand",
"label": "Brand-in-subdomain attack",
"status": "fail"
},
{
"detail": "Credential collection form detected on the page",
"effect": "increases",
"id": "credential_form",
"label": "Credential collection form",
"status": "fail"
},
{
"detail": "Served over HTTPS · valid TLS certificate",
"effect": "decreases",
"id": "ssl",
"label": "SSL certificate",
"status": "pass"
}
],
"enrichment_sources": [
"rdap",
"dns",
"asn"
],
"model": "gpt-5.4-nano"
}
}
{
"status": "in_progress"
}
{
"error": "Invalid job ID"
}
{
"error": "Job not found"
}
{
"error": "fetch failed: timeout"
}
Try it
/api/scan/:id/reinspect
required · scope: scan
Re-detonate a stored scan on demand (time-of-click protection)
Re-enqueues a *fresh* scan for the URL of an existing completed scan and returns a new `job_id` to poll with `GET /api/scan/{job_id}`. Attackers park a page benign until links are delivered, then flip it to a phish — re-inspecting at click time catches that flip. No cache short-circuit (it always starts a new scan), and the re-enqueue funnels through the same SSRF guard and per-key/per-org daily throttle as `POST /api/scan`. The `id` is the original `job_id`; the source scan is resolved org-scoped to your key, so another org's job id returns 404. Requires the `scan` scope.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | integer | yes | The job id of an existing scan (from POST /api/scan). |
Code samples
curl -X POST http://zerophish.ai/api/scan/918273/reinspect \
-H "Authorization: Bearer $ZEROPHISH_API_KEY" \
-H "Content-Type: application/json" \
-d 'null'
import requests
API_KEY = "zp_live_…" # from /app/settings/api-keys
resp = requests.post(
"http://zerophish.ai/api/scan/918273/reinspect",
headers={"Authorization": f"Bearer {API_KEY}"},
json=None,
)
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/reinspect", {
method: "POST",
headers: {
"Authorization": `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(null),
});
const data = await resp.json();
console.log(resp.status, data);
Responses
{
"cached": false,
"deep": false,
"job_id": 918299
}
{
"error": "Invalid job ID"
}
{
"error": "authentication_required"
}
{
"error": "Scan not found"
}
{
"error": "Daily request limit exceeded"
}
Try it
/api/scan/:id/history
required · scope: read
Chronological verdict history for a scan's URL
Returns every scan your organisation has run for the same (normalised) URL, newest first, each with `verdict`, `phishing_score`, `confidence` and `inserted_at`. The safe→phishing flip a time-of-click attack produces is visible as the verdict changing across rows. The `id` is a `job_id` (from POST /api/scan); the source scan is resolved org-scoped to your key, so another org's job id returns 404, and the history itself is tenant-filtered. Requires the `read` scope.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | integer | yes | A job id (from POST /api/scan) for any scan of the URL whose history you want. |
Code samples
curl "http://zerophish.ai/api/scan/918273/history" \
-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/history",
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/history", {
headers: {
"Authorization": `Bearer ${API_KEY}`,
},
});
const data = await resp.json();
console.log(resp.status, data);
Responses
{
"count": 2,
"history": [
{
"confidence": "high",
"id": "b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f",
"inserted_at": "2026-05-30T18:04:11.000000Z",
"job_id": 918299,
"phishing_score": 0.97,
"verdict": "phishing"
},
{
"confidence": "medium",
"id": "a1a1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e60",
"inserted_at": "2026-05-30T12:01:55.000000Z",
"job_id": 918273,
"phishing_score": 0.08,
"verdict": "safe"
}
],
"url": "https://login.examplebank.com.verify-account.example/"
}
{
"error": "Invalid job ID"
}
{
"error": "authentication_required"
}
{
"error": "Scan not found"
}
Try it
/api/predictions/:id/tags
required · scope: scan
Add a tag to a scan
Attach a tag to one of your organisation's scans — handy for labelling results from a SOAR/automation pipeline. The `id` is the prediction UUID from search/lookup/feeds. Scoped to the key's organisation. Requires the `scan` scope.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Prediction UUID. |
| tag | body | string | yes | Tag to add. |
Code samples
curl -X POST http://zerophish.ai/api/predictions/b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f/tags \
-H "Authorization: Bearer $ZEROPHISH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"tag":"credential-harvesting"}'
import requests
API_KEY = "zp_live_…" # from /app/settings/api-keys
resp = requests.post(
"http://zerophish.ai/api/predictions/b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f/tags",
headers={"Authorization": f"Bearer {API_KEY}"},
json={"tag": "credential-harvesting"},
)
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/predictions/b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f/tags", {
method: "POST",
headers: {
"Authorization": `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"tag":"credential-harvesting"}),
});
const data = await resp.json();
console.log(resp.status, data);
Responses
{
"id": "b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f",
"tags": [
"credential-harvesting"
]
}
{
"error": "tag is required"
}
{
"error": "authentication_required"
}
{
"error": "prediction not found"
}
Try it
/api/predictions/:id/tags/:tag
required · scope: scan
Remove a tag from a scan
Detach a tag from one of your organisation's scans. The `id` is the prediction UUID. Scoped to the key's organisation. Requires the `scan` scope.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Prediction UUID. |
| tag | path | string | yes | Tag to remove. |
Code samples
curl "http://zerophish.ai/api/predictions/b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f/tags/credential-harvesting" \
-H "Authorization: Bearer $ZEROPHISH_API_KEY"
import requests
API_KEY = "zp_live_…" # from /app/settings/api-keys
resp = requests.get(
"http://zerophish.ai/api/predictions/b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f/tags/credential-harvesting",
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/predictions/b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f/tags/credential-harvesting", {
headers: {
"Authorization": `Bearer ${API_KEY}`,
},
});
const data = await resp.json();
console.log(resp.status, data);
Responses
{
"id": "b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f",
"tags": []
}
{
"error": "authentication_required"
}
{
"error": "prediction not found"
}
Try it
/api/predictions/:id/graph
required · scope: read
IoC relationship graph for a scan (JSON or GraphML)
The full IoC relationship graph centred on one scan — the same `{nodes, edges}` payload behind the app's graph canvas. Nodes cover the seed and its structural-fingerprint siblings (favicon MD5/perceptual-hash, JARM, DOM skeleton, screenshot perceptual/difference hash, campaign), the campaign, each prediction's serving domain and the seed's hosting ASN, the HTTP redirect chain, matched phishing kits, the targeted brand, MITRE ATT&CK techniques, hostile infrastructure (credential-exfil backends, phishing frontends, plus deep-scan form-exfil targets), resolved IPs with reverse passive-DNS co-hosted neighbours, detonator-intercepted files (with a MalwareBazaar-known flag), and the certificate-transparency subdomain footprint. Host-shaped nodes are annotated with OSINT blocklist corroboration (`blocklisted`, `blocklist_sources`) and open takedown-case status (`takedown_status`). Node ids are stable type-prefixed strings (`prediction:<uuid>`, `host:<uuid>`, `ipv4:<addr>`, `domain:<host>`, `kit:<name>`, …) and the element model is Cytoscape-compatible, so the payload drops straight into graph libraries. Pass `?format=graphml` to instead receive a GraphML XML document for desktop tooling (Gephi, yEd, Cytoscape Desktop, Maltego transforms). The traversal is bounded (depth 1, per-collection caps, ~120-node ceiling) and org-scoped by the seed UUID. Requires the `read` scope.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Seed prediction UUID (from search/lookup/feeds). |
| format | query | string | no | Set to `graphml` for a GraphML XML document instead of JSON. |
Code samples
curl "http://zerophish.ai/api/predictions/b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f/graph?format=graphml" \
-H "Authorization: Bearer $ZEROPHISH_API_KEY"
import requests
API_KEY = "zp_live_…" # from /app/settings/api-keys
resp = requests.get(
"http://zerophish.ai/api/predictions/b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f/graph",
headers={"Authorization": f"Bearer {API_KEY}"},
params={"format": "graphml"},
)
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/predictions/b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f/graph?format=graphml", {
headers: {
"Authorization": `Bearer ${API_KEY}`,
},
});
const data = await resp.json();
console.log(resp.status, data);
Responses
{
"edges": [
{
"id": "prediction:b3b1f0e2-…->domain:login.…:hosted-on",
"label": "hosted-on",
"source": "prediction:b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f",
"target": "domain:login.examplebank.com.verify-account.example"
}
],
"nodes": [
{
"id": "prediction:b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f",
"label": "https://login.examplebank.com.verify-account.example/session",
"pivot": "scan",
"ref": "/app/scans/b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f",
"seed": true,
"type": "prediction",
"verdict": "phishing"
},
{
"blocklist_sources": [
"urlhaus"
],
"blocklisted": true,
"id": "domain:login.examplebank.com.verify-account.example",
"label": "login.examplebank.com.verify-account.example",
"pivot": "domain",
"type": "domain"
},
{
"id": "asn:64500",
"label": "AS64500",
"pivot": "asn",
"type": "asn"
}
],
"seed_id": "b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f"
}
{
"error": "authentication_required"
}
{
"error": "prediction not found"
}
Try it
/api/predictions/:id/sigma
required · scope: read
Download a Sigma detection rule for a scan
Returns a [Sigma](https://github.com/SigmaHQ/sigma) detection rule (YAML) for one scan's phishing infrastructure. The rule's `detection.selection` matches the scanned host plus every host in the scan's redirect chain against the generic proxy/DNS taxonomy fields `c-uri|contains` and `dns-query|contains`, so the same rule applies to both proxy and DNS logs. `logsource` is intentionally generic (`category: proxy`) because ZerophishEx cannot know your SIEM's field mapping — adapt `c-uri` / `dns-query` to your schema. `level` is derived from the verdict and confidence; `tags` is always `attack.phishing`. The response is `application/yaml`. Scoped to the key's organisation by the prediction UUID. Requires the `read` scope.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Prediction UUID (from search/lookup/feeds). |
Code samples
curl "http://zerophish.ai/api/predictions/b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f/sigma" \
-H "Authorization: Bearer $ZEROPHISH_API_KEY"
import requests
API_KEY = "zp_live_…" # from /app/settings/api-keys
resp = requests.get(
"http://zerophish.ai/api/predictions/b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f/sigma",
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/predictions/b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f/sigma", {
headers: {
"Authorization": `Bearer ${API_KEY}`,
},
});
const data = await resp.json();
console.log(resp.status, data);
Responses
"title: \"Phishing impersonating Example Bank: login.examplebank.com.verify-account.example\"\nstatus: experimental\nlogsource:\n category: proxy\ndetection:\n selection:\n c-uri|contains:\n - \"login.examplebank.com.verify-account.example\"\n dns-query|contains:\n - \"login.examplebank.com.verify-account.example\"\n condition: selection\ntags:\n - attack.phishing\nlevel: high"
{
"error": "authentication_required"
}
{
"error": "prediction not found"
}
Try it
/api/predictions/:id/har
required · scope: read
Download the HAR archive (or parsed network-log) for a scan
Download the full HTTP Archive (HAR) captured during a deep or agent scan — every request/response, headers, timings and bodies — ready to import into Burp Suite or Chrome DevTools and correlate with your proxy logs. The bytes are proxied through ZerophishEx (not a public bucket redirect), so the artifact is only ever served to an authenticated, org-scoped caller; the response is `application/json` with a `Content-Disposition: attachment` filename. Quick scans capture no HAR and return 404. Pass `?format=netlog` to instead get a compact JSON object with the scan's parsed `redirect_chain` (and a `har_available` flag) without downloading the full archive. The `id` is the prediction UUID (from search/lookup/feeds). Scoped to the key's organisation by the prediction UUID (another org's scan returns 404). Requires the `read` scope.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Prediction UUID (from search/lookup/feeds). |
| format | query | string | no | Pass `netlog` to return the parsed redirect/network-log JSON instead of the raw HAR archive. |
Code samples
curl "http://zerophish.ai/api/predictions/b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f/har?format=netlog" \
-H "Authorization: Bearer $ZEROPHISH_API_KEY"
import requests
API_KEY = "zp_live_…" # from /app/settings/api-keys
resp = requests.get(
"http://zerophish.ai/api/predictions/b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f/har",
headers={"Authorization": f"Bearer {API_KEY}"},
params={"format": "netlog"},
)
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/predictions/b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f/har?format=netlog", {
headers: {
"Authorization": `Bearer ${API_KEY}`,
},
});
const data = await resp.json();
console.log(resp.status, data);
Responses
{
"har_available": true,
"id": "b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f",
"redirect_chain": [
{
"url": "https://verify-account.example/landing"
},
{
"url": "https://login.examplebank.com.verify-account.example/"
}
],
"url": "https://login.examplebank.com.verify-account.example/"
}
{
"error": "authentication_required"
}
{
"error": "no HAR captured for this scan"
}
Try it
/api/predictions/:id/files
required · scope: read
List the files a scan's detonation intercepted
Enumerate the downloaded-file inventory for one scan — every file the headless-browser detonation intercepted during a deep/agent scan, promoted into a queryable malware inventory. Each row carries the file's size, its magic-byte sniffed MIME type (`mime_sniffed`, independent of the server-advertised type that kits routinely lie about), a full multi-hash set (`md5`, `sha1`, `sha256`, plus an `ssdeep` fuzzy hash for near-duplicate clustering), the CDN `object_key` the bytes were archived under, and a non-fatal MalwareBazaar SHA-256 cross-check (`malware_bazaar`: `{"known": true, "signature": …}` when the abuse.ch corpus knows the hash, `{"known": false}` when it doesn't, or an empty object when the lookup couldn't run). Quick scans capture no files and return an empty list. The `id` is the prediction UUID (from search/lookup/feeds). Scoped to the key's organisation by the prediction UUID (another org's scan returns 404). Requires the `read` scope.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Prediction UUID (from search/lookup/feeds). |
Code samples
curl "http://zerophish.ai/api/predictions/b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f/files" \
-H "Authorization: Bearer $ZEROPHISH_API_KEY"
import requests
API_KEY = "zp_live_…" # from /app/settings/api-keys
resp = requests.get(
"http://zerophish.ai/api/predictions/b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f/files",
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/predictions/b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f/files", {
headers: {
"Authorization": `Bearer ${API_KEY}`,
},
});
const data = await resp.json();
console.log(resp.status, data);
Responses
{
"count": 1,
"files": [
{
"filename": "invoice.exe",
"id": "f1a2b3c4-0000-0000-0000-000000000000",
"malware_bazaar": {
"file_type": "exe",
"known": true,
"signature": "AgentTesla",
"tags": [
"exe",
"AgentTesla"
]
},
"md5": "d41d8cd98f00b204e9800998ecf8427e",
"mime_sniffed": "application/x-dosexec",
"object_key": "downloads/e3b0c4…-invoice.exe",
"sha1": "da39a3ee5e6b4b0d3255bfef95601890afd80709",
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"size": 184320,
"ssdeep": "3072:abcd…",
"url": "https://verify-account.example/dl/invoice.exe"
}
],
"id": "b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f"
}
{
"error": "authentication_required"
}
{
"error": "prediction not found"
}
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. |
| sector | query | string | no | Filter by brand industry sector — expands to every catalogue brand in that vertical and matches any of them, so a finance CSIRT can hunt all banking-brand phishing at once. One of: crypto, ecommerce, entertainment, finance, gaming, government, shipping, social, technology. Unknown sectors match nothing. |
| 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). |
| from | query | string | no | Only scans on/after this date (ISO-8601, UTC), e.g. 2026-05-01. |
| to | query | string | no | Only scans before the day after this date (ISO-8601, UTC), inclusive. |
| favicon_md5 | query | string | no | Exact-match favicon MD5 — pivot to find kit infrastructure reusing the same favicon. |
| jarm | query | string | no | Exact-match JARM TLS fingerprint — pivot to find hosts with the same TLS stack. |
| dom_skeleton | query | string | no | Exact-match DOM skeleton hash — pivot to find pages with the same structure. |
| asn | query | integer | no | Exact-match autonomous system number of the resolved host — pivot to find scans hosted on the same network. |
| scan_depth | query | string | no | Filter by scan tier: `quick` (default screenshot scan) or `deep` (headless-browser detonation). |
| has_cert | query | boolean | no | Only scans that did (`true`) or did not (`false`) capture a TLS certificate chain. |
| has_redirects | query | boolean | no | Only scans whose redirect chain has more than one hop (`true`) or at most one (`false`) — surfaces cloaked / multi-hop landing pages. |
| max_domain_age_days | query | integer | no | Only scans whose registrable domain is at most N days old (newly-registered domains are a strong phishing indicator). Excludes scans with unknown domain age. |
| dga_score_min | query | number | no | Only scans whose registrable domain label has a static DGA lexical-randomness score (Shannon entropy + character-bigram improbability, 0.0–1.0) of at least this value. Random-looking / kit-generated / AiTM-redirector hostnames (e.g. `kjxqwzpvbnml.com`) score high; real brand labels score low. Computed in-process at scan time (no network); short brand/acronym labels are exempt and never match. Scans with no DGA score never match. |
| kit | query | string | no | Filter by matched phishing-kit family — case-insensitive substring on `kit_matches[].name`. The kit-family threat-hunting pivot: surface every scan attributed to a kit (e.g. 16Shop, Tycoon 2FA, EvilProxy). |
| technique | query | string | no | Filter by live attack technique observed during a deep-scan detonation (`dynamic_findings.techniques`). Supported: `clickfix` — the fake-CAPTCHA / clipboard-write lure that writes a PowerShell/mshta/curl command cradle to the clipboard behind a 'verify you are human' prompt; `bot_exfil` — a Telegram (`api.telegram.org/bot…/sendMessage`) or Discord webhook bot C2 exfil endpoint seen in the captured network log (the bot token is scrubbed before storage). Also accepts a lowercased MITRE ATT&CK technique id (e.g. `t1557`, `t1583.001`) derived from the resolved signal list and surfaced on each scan's `ttp_tags`. Case-insensitive. |
| has_credential_harvest_cookie | query | boolean | no | Only deep scans where the headless browser read a client-side cookie or localStorage key whose *name* classifies as a credential (token/password/cc/otp/secret-style) — a strong credential-prefill/exfil tell no static scanner can see (`dynamic_findings.has_credential_harvest_cookie`). Only raw key names + classifications are stored, never cookie/storage values. Only `true` filters; legacy/quick scans never match. |
| campaign | query | string | no | Filter to scans clustered into the given campaign (its UUID, from `GET /api/campaigns`). Unclustered scans never match. |
| has_downloads | query | boolean | no | Only scans that intercepted at least one downloaded file (`true`) or none (`false`) — surfaces malware-delivery pages. Enumerate the files with `GET /api/predictions/{id}/files`. |
| file_mime | query | string | no | Only scans that captured a file whose magic-byte sniffed MIME type matches this value (case-insensitive substring). The malware-delivery pivot — e.g. `application/x-dosexec` for a dropped Windows executable, or just `pdf`. |
| lang | query | string | no | Filter by detected page language (ISO 639-1 code, e.g. `en`, `de`, `fr`, `es`, `pt`, `it`, `ru`, or `zh` for Chinese). Best-effort, computed from the page text at scan time; scans where the language was inconclusive never match. |
| ocr | query | string | no | Full-text search over the rendered-screenshot OCR text (`screenshot_ocr_text`) — the words Tesseract read from the page screenshot at scan time. This is the only way to search image-only phishing kits that rasterise their brand logo + form labels and so carry no DOM text for `q` to match. Parsed with Postgres `websearch_to_tsquery`, so Google-style operators work: quote phrases (`"verify your account"`) and exclude terms with a leading `-`. Scans with no captured OCR text never match. |
| limit | query | integer | no | Max rows to return (1–200, default 50). |
| offset | query | integer | no | Rows to skip for pagination (default 0). Combine with `total` in the response to page through results. |
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,
"limit": 50,
"offset": 0,
"scans": [
{
"brands": "Example Bank",
"campaign_id": null,
"confidence": "high",
"duration_ms": 8421,
"enrichment": {
"asn": 13335,
"asn_org": "Cloudflare, Inc.",
"caa_issuers": [],
"caa_present": false,
"cert_issuer": "C=US, O=Let's Encrypt, CN=R10",
"cert_not_before": "2026-06-23T00:00:00Z",
"country": "United States (US)",
"dmarc_policy": null,
"dnssec_signed": false,
"domain_age_days": 4,
"mx_present": true,
"registered_at": "2026-06-23T11:42:00Z",
"registrar": "NameSilo, LLC",
"spf_policy": "soft"
},
"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"
],
"threat_intel": {
"sources": [
"urlhaus",
"openphish"
]
},
"ttp_tags": [
{
"id": "T1583.001",
"name": "Acquire Infrastructure: Domains",
"tactic": "Resource Development",
"url": "https://attack.mitre.org/techniques/T1583/001/"
},
{
"id": "T1056.003",
"name": "Input Capture: Web Portal Capture",
"tactic": "Credential Access",
"url": "https://attack.mitre.org/techniques/T1056/003/"
}
],
"url": "https://login.examplebank.com.verify-account.example/",
"url_parts": {
"host": "login.examplebank.com.verify-account.example",
"hostname_skeleton": "login.examplebank.com.verify-account.example",
"registrable_domain": "verify-account.example",
"scheme": "https"
},
"verdict": "phishing",
"verdict_rationale": {
"contributing": [
{
"detail": "Real brand \"examplebank\" used as subdomain of attacker-owned root",
"effect": "increases",
"id": "subdomain_brand",
"label": "Brand-in-subdomain attack",
"status": "fail"
},
{
"detail": "Credential collection form detected on the page",
"effect": "increases",
"id": "credential_form",
"label": "Credential collection form",
"status": "fail"
},
{
"detail": "Served over HTTPS · valid TLS certificate",
"effect": "decreases",
"id": "ssl",
"label": "SSL certificate",
"status": "pass"
}
],
"enrichment_sources": [
"rdap",
"dns",
"asn"
],
"model": "gpt-5.4-nano"
}
}
],
"total": 137
}
{
"error": "invalid_query"
}
{
"error": "authentication_required"
}
Try it
/api/campaigns
required · scope: read
List your organisation's phishing campaigns
Lists the campaign clusters ZerophishEx has grouped from your scans — each cluster is a set of scans sharing infrastructure fingerprints (JARM / favicon MD5 / DOM skeleton). Newest activity first. Paginate with `limit` (1–200, default 50) and `offset`; `total` is the full count. The organisation is taken from the API key automatically. Requires the `read` scope. Pull a campaign's member scans with `GET /api/campaigns/{id}`, or scope any feed/search to one campaign via `?campaign={id}`.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| limit | query | integer | no | Max campaigns to return (1–200, default 50). |
| offset | query | integer | no | Campaigns to skip for pagination (default 0). |
Code samples
curl "http://zerophish.ai/api/campaigns?limit=50" \
-H "Authorization: Bearer $ZEROPHISH_API_KEY"
import requests
API_KEY = "zp_live_…" # from /app/settings/api-keys
resp = requests.get(
"http://zerophish.ai/api/campaigns",
headers={"Authorization": f"Bearer {API_KEY}"},
params={"limit": 50},
)
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/campaigns?limit=50", {
headers: {
"Authorization": `Bearer ${API_KEY}`,
},
});
const data = await resp.json();
console.log(resp.status, data);
Responses
{
"campaigns": [
{
"dominant_favicon_md5": "d41d8cd98f00b204e9800998ecf8427e",
"dominant_jarm": "27d40d40d29d40d1dc42d43d00041d",
"dominant_skeleton_hash": "a1b2c3d4e5f6",
"first_seen": "2026-06-01T08:00:00.000000Z",
"id": "c1a2m3p4-0000-0000-0000-000000000000",
"last_seen": "2026-06-20T14:30:00.000000Z",
"member_count": 12,
"primary_brand": "Acme Bank",
"slug": "acme-bank-2026-06"
}
],
"count": 1,
"limit": 50,
"offset": 0,
"total": 1
}
{
"error": "authentication_required"
}
Try it
/api/campaigns/:id
required · scope: read
Fetch a campaign with its member scans
Returns one campaign cluster plus its member predictions — the scans whose `campaign_id` points at it. Paginate the members with `limit` (1–200, default 50) and `offset`; `member_count` is the total membership. The `id` is the campaign UUID from `GET /api/campaigns`. Scoped to the key's organisation — another org's campaign id is a 404, so campaigns can't be enumerated by UUID. Requires the `read` scope.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Campaign UUID (from GET /api/campaigns). |
| limit | query | integer | no | Max member scans to return (1–200, default 50). |
| offset | query | integer | no | Member scans to skip for pagination (default 0). |
Code samples
curl "http://zerophish.ai/api/campaigns/c1a2m3p4-0000-0000-0000-000000000000?limit=50" \
-H "Authorization: Bearer $ZEROPHISH_API_KEY"
import requests
API_KEY = "zp_live_…" # from /app/settings/api-keys
resp = requests.get(
"http://zerophish.ai/api/campaigns/c1a2m3p4-0000-0000-0000-000000000000",
headers={"Authorization": f"Bearer {API_KEY}"},
params={"limit": 50},
)
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/campaigns/c1a2m3p4-0000-0000-0000-000000000000?limit=50", {
headers: {
"Authorization": `Bearer ${API_KEY}`,
},
});
const data = await resp.json();
console.log(resp.status, data);
Responses
{
"campaign": {
"dominant_favicon_md5": "d41d8cd98f00b204e9800998ecf8427e",
"dominant_jarm": "27d40d40d29d40d1dc42d43d00041d",
"dominant_skeleton_hash": "a1b2c3d4e5f6",
"first_seen": "2026-06-01T08:00:00.000000Z",
"id": "c1a2m3p4-0000-0000-0000-000000000000",
"last_seen": "2026-06-20T14:30:00.000000Z",
"member_count": 12,
"primary_brand": "Acme Bank",
"slug": "acme-bank-2026-06"
},
"limit": 50,
"member_count": 12,
"members": [
{
"brands": "Example Bank",
"campaign_id": null,
"confidence": "high",
"duration_ms": 8421,
"enrichment": {
"asn": 13335,
"asn_org": "Cloudflare, Inc.",
"caa_issuers": [],
"caa_present": false,
"cert_issuer": "C=US, O=Let's Encrypt, CN=R10",
"cert_not_before": "2026-06-23T00:00:00Z",
"country": "United States (US)",
"dmarc_policy": null,
"dnssec_signed": false,
"domain_age_days": 4,
"mx_present": true,
"registered_at": "2026-06-23T11:42:00Z",
"registrar": "NameSilo, LLC",
"spf_policy": "soft"
},
"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"
],
"threat_intel": {
"sources": [
"urlhaus",
"openphish"
]
},
"ttp_tags": [
{
"id": "T1583.001",
"name": "Acquire Infrastructure: Domains",
"tactic": "Resource Development",
"url": "https://attack.mitre.org/techniques/T1583/001/"
},
{
"id": "T1056.003",
"name": "Input Capture: Web Portal Capture",
"tactic": "Credential Access",
"url": "https://attack.mitre.org/techniques/T1056/003/"
}
],
"url": "https://login.examplebank.com.verify-account.example/",
"url_parts": {
"host": "login.examplebank.com.verify-account.example",
"hostname_skeleton": "login.examplebank.com.verify-account.example",
"registrable_domain": "verify-account.example",
"scheme": "https"
},
"verdict": "phishing",
"verdict_rationale": {
"contributing": [
{
"detail": "Real brand \"examplebank\" used as subdomain of attacker-owned root",
"effect": "increases",
"id": "subdomain_brand",
"label": "Brand-in-subdomain attack",
"status": "fail"
},
{
"detail": "Credential collection form detected on the page",
"effect": "increases",
"id": "credential_form",
"label": "Credential collection form",
"status": "fail"
},
{
"detail": "Served over HTTPS · valid TLS certificate",
"effect": "decreases",
"id": "ssl",
"label": "SSL certificate",
"status": "pass"
}
],
"enrichment_sources": [
"rdap",
"dns",
"asn"
],
"model": "gpt-5.4-nano"
}
}
],
"offset": 0
}
{
"error": "authentication_required"
}
{
"error": "campaign not found"
}
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. The returned prediction carries the derived `enrichment` block, an org-scoped `prevalence` map ("seen in N scans" for each artifact — favicon, DOM skeleton, JARM, ASN, screenshot hash, registrable domain), and a `dns_history` array — passive-DNS resolution history (`host` → `ip` over time, with `first_seen`/`last_seen`/`resolution_count`) mined from **your own org's** scan corpus only. 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",
"dns_history": [
{
"asn": 13335,
"first_seen": "2026-05-20T09:00:00.000000Z",
"host": "example.com",
"ip": "203.0.113.5",
"last_seen": "2026-05-30T12:01:55.000000Z",
"resolution_count": 4
}
],
"duration_ms": 8421,
"enrichment": {
"asn": 13335,
"asn_org": "Cloudflare, Inc.",
"caa_issuers": [],
"caa_present": false,
"cert_issuer": "C=US, O=Let's Encrypt, CN=R10",
"cert_not_before": "2026-06-23T00:00:00Z",
"country": "United States (US)",
"dmarc_policy": null,
"dnssec_signed": false,
"domain_age_days": 4,
"mx_present": true,
"registered_at": "2026-06-23T11:42:00Z",
"registrar": "NameSilo, LLC",
"spf_policy": "soft"
},
"id": "b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f",
"jarm": "27d40d40d29d40d1dc42d43d00041d...",
"kit_matches": [],
"normalized_url": "login.examplebank.com.verify-account.example/",
"phishing": true,
"phishing_score": 0.94,
"prevalence": {
"domain": {
"count": 12,
"value": "example.com"
},
"favicon_md5": {
"count": 3,
"value": "ab12cd34"
}
},
"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"
],
"threat_intel": {
"sources": [
"urlhaus",
"openphish"
]
},
"ttp_tags": [
{
"id": "T1583.001",
"name": "Acquire Infrastructure: Domains",
"tactic": "Resource Development",
"url": "https://attack.mitre.org/techniques/T1583/001/"
},
{
"id": "T1056.003",
"name": "Input Capture: Web Portal Capture",
"tactic": "Credential Access",
"url": "https://attack.mitre.org/techniques/T1056/003/"
}
],
"url": "https://login.examplebank.com.verify-account.example/",
"url_parts": {
"host": "login.examplebank.com.verify-account.example",
"hostname_skeleton": "login.examplebank.com.verify-account.example",
"registrable_domain": "verify-account.example",
"scheme": "https"
},
"verdict": "phishing",
"verdict_rationale": {
"contributing": [
{
"detail": "Real brand \"examplebank\" used as subdomain of attacker-owned root",
"effect": "increases",
"id": "subdomain_brand",
"label": "Brand-in-subdomain attack",
"status": "fail"
},
{
"detail": "Credential collection form detected on the page",
"effect": "increases",
"id": "credential_form",
"label": "Credential collection form",
"status": "fail"
},
{
"detail": "Served over HTTPS · valid TLS certificate",
"effect": "decreases",
"id": "ssl",
"label": "SSL certificate",
"status": "pass"
}
],
"enrichment_sources": [
"rdap",
"dns",
"asn"
],
"model": "gpt-5.4-nano"
}
},
"scanned_at": "2026-05-30T12:01:55.000000Z",
"status": "known"
}
{
"status": "unknown"
}
{
"error": "url_required"
}
{
"error": "authentication_required"
}
Try it
/api/lookup/bulk
required · scope: read
Bulk cache lookup — resolve many URLs to verdicts in one call
Resolve up to 100 URLs to their latest known verdict in a single call, without enqueuing any scans. The primary SOAR-integration lever: one round-trip maps each input URL to a compact cached prediction summary or `{status: "unknown"}`. Each URL is normalised for matching, but results are keyed by the exact input string you sent so you can correlate by it. Inputs are de-duplicated; the distinct set is capped at 100. The organisation is taken from the API key automatically. Requires the `read` scope.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| urls | body | array | yes | Array of URLs to look up (1–100, de-duplicated). |
Code samples
curl -X POST http://zerophish.ai/api/lookup/bulk \
-H "Authorization: Bearer $ZEROPHISH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"urls":["https://example.com","https://login.examplebank.com.verify.example/"]}'
import requests
API_KEY = "zp_live_…" # from /app/settings/api-keys
resp = requests.post(
"http://zerophish.ai/api/lookup/bulk",
headers={"Authorization": f"Bearer {API_KEY}"},
json={"urls": ["https://example.com", "https://login.examplebank.com.verify.example/"]},
)
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/bulk", {
method: "POST",
headers: {
"Authorization": `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"urls":["https://example.com","https://login.examplebank.com.verify.example/"]}),
});
const data = await resp.json();
console.log(resp.status, data);
Responses
{
"count": 2,
"results": {
"https://example.com": {
"status": "unknown"
},
"https://login.examplebank.com.verify.example/": {
"cached": true,
"prediction": {
"brands": "Example Bank",
"confidence": "high",
"id": "b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f",
"phishing_score": 0.97,
"scanned_at": "2026-05-30T12:01:55.000000Z",
"tags": [
"credential-harvesting"
],
"url": "https://login.examplebank.com.verify.example/",
"verdict": "phishing"
},
"scanned_at": "2026-05-30T12:01:55.000000Z",
"status": "known"
}
}
}
{
"error": "too_many_urls",
"max": 100
}
{
"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,
"enrichment": {
"asn": 13335,
"asn_org": "Cloudflare, Inc.",
"caa_issuers": [],
"caa_present": false,
"cert_issuer": "C=US, O=Let's Encrypt, CN=R10",
"cert_not_before": "2026-06-23T00:00:00Z",
"country": "United States (US)",
"dmarc_policy": null,
"dnssec_signed": false,
"domain_age_days": 4,
"mx_present": true,
"registered_at": "2026-06-23T11:42:00Z",
"registrar": "NameSilo, LLC",
"spf_policy": "soft"
},
"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"
],
"threat_intel": {
"sources": [
"urlhaus",
"openphish"
]
},
"ttp_tags": [
{
"id": "T1583.001",
"name": "Acquire Infrastructure: Domains",
"tactic": "Resource Development",
"url": "https://attack.mitre.org/techniques/T1583/001/"
},
{
"id": "T1056.003",
"name": "Input Capture: Web Portal Capture",
"tactic": "Credential Access",
"url": "https://attack.mitre.org/techniques/T1056/003/"
}
],
"url": "https://login.examplebank.com.verify-account.example/",
"url_parts": {
"host": "login.examplebank.com.verify-account.example",
"hostname_skeleton": "login.examplebank.com.verify-account.example",
"registrable_domain": "verify-account.example",
"scheme": "https"
},
"verdict": "phishing",
"verdict_rationale": {
"contributing": [
{
"detail": "Real brand \"examplebank\" used as subdomain of attacker-owned root",
"effect": "increases",
"id": "subdomain_brand",
"label": "Brand-in-subdomain attack",
"status": "fail"
},
{
"detail": "Credential collection form detected on the page",
"effect": "increases",
"id": "credential_form",
"label": "Credential collection form",
"status": "fail"
},
{
"detail": "Served over HTTPS · valid TLS certificate",
"effect": "decreases",
"id": "ssl",
"label": "SSL certificate",
"status": "pass"
}
],
"enrichment_sources": [
"rdap",
"dns",
"asn"
],
"model": "gpt-5.4-nano"
}
},
"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 graph feed of confirmed phishing
Exports your organisation's confirmed-phishing URLs from the last 90 days as a STIX 2.1 bundle — drop straight into a TIP (MISP, OpenCTI, Sentinel). Rather than a flat indicator list, the bundle is a linked graph: an `indicator` per confirmed URL, a `campaign` object per distinct campaign cluster, an `infrastructure` object (plus `ipv4-addr`/`domain-name` observables with ASN) per associated hostile host, and `relationship` edges (`indicates` indicator→campaign, `consists-of` campaign→infrastructure, `communicates-with` indicator→infrastructure). Every object carries `object_marking_refs` pointing at a single TLP `marking-definition` (default `TLP:AMBER`; override with `?tlp=clear|green|amber|red`). STIX object ids are deterministic (UUIDv5) so re-pulls dedupe downstream. The organisation is taken from the API key automatically. Supports ETag / If-None-Match conditional polling: every response carries a weak `ETag` + `Last-Modified`, and re-sending the ETag in `If-None-Match` returns `304 Not Modified` (empty body) while the feed is unchanged — poll frequently without re-downloading the corpus. Requires the `read` scope.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| tlp | query | string | no | Traffic Light Protocol marking applied to every object: `clear`, `green`, `amber` (default), or `red`. |
| brand | query | string | no | Filter to confirmed-phishing scans whose `brands` field contains this case-insensitive substring (e.g. only the phishing targeting Acme). Free-text match; absent = all brands. |
| sector | query | string | no | Filter to confirmed-phishing scans targeting any brand in this industry sector — expands to every catalogue brand in the vertical (e.g. `finance` covers PayPal, Stripe and the named banks). One of: crypto, ecommerce, entertainment, finance, gaming, government, shipping, social, technology. Unknown sectors match nothing; absent = all sectors. |
| campaign | query | string | no | Filter to confirmed-phishing scans clustered into the given campaign (its UUID, from `GET /api/campaigns`) — pull one entire campaign as a feed. Unclustered scans never match; absent = all campaigns. |
Code samples
curl "http://zerophish.ai/api/feeds/stix.json?tlp=amber" \
-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}"},
params={"tlp": "amber"},
)
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?tlp=amber", {
headers: {
"Authorization": `Bearer ${API_KEY}`,
},
});
const data = await resp.json();
console.log(resp.status, data);
Responses
{
"id": "bundle--6f1d…",
"objects": [
{
"definition": {
"tlp": "amber"
},
"definition_type": "tlp",
"id": "marking-definition--f88d31f6-486f-44da-b317-01333bde0b82",
"name": "TLP:AMBER",
"spec_version": "2.1",
"type": "marking-definition"
},
{
"confidence": 90,
"id": "indicator--b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f",
"indicator_types": [
"malicious-activity"
],
"labels": [
"phishing"
],
"object_marking_refs": [
"marking-definition--f88d31f6-486f-44da-b317-01333bde0b82"
],
"pattern": "[url:value = 'https://login.examplebank.com.verify-account.example/']",
"pattern_type": "stix",
"spec_version": "2.1",
"type": "indicator"
},
{
"id": "infrastructure--2c4a4f9d-9d2e-1a2b-3c4d-5e6fb3b1f0e2",
"infrastructure_types": [
"phishing"
],
"name": "exfil.attacker.example (credential_exfil)",
"object_marking_refs": [
"marking-definition--f88d31f6-486f-44da-b317-01333bde0b82"
],
"spec_version": "2.1",
"type": "infrastructure"
},
{
"id": "relationship--1a2b3c4d-5e6f-4f9d-9d2e-2c4ab3b1f0e2",
"object_marking_refs": [
"marking-definition--f88d31f6-486f-44da-b317-01333bde0b82"
],
"relationship_type": "communicates-with",
"source_ref": "indicator--b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f",
"spec_version": "2.1",
"target_ref": "infrastructure--2c4a4f9d-9d2e-1a2b-3c4d-5e6fb3b1f0e2",
"type": "relationship"
}
],
"type": "bundle"
}
""
{
"error": "authentication_required"
}
Try it
/api/usage
required · scope: read
Your API key's daily quota usage
Returns how much of today's scan-request quota the authenticated key has consumed. Read-only — it does not count against the quota. Use it to self-throttle and show remaining capacity without provoking a 429. Requires the `read` scope.
Code samples
curl "http://zerophish.ai/api/usage" \
-H "Authorization: Bearer $ZEROPHISH_API_KEY"
import requests
API_KEY = "zp_live_…" # from /app/settings/api-keys
resp = requests.get(
"http://zerophish.ai/api/usage",
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/usage", {
headers: {
"Authorization": `Bearer ${API_KEY}`,
},
});
const data = await resp.json();
console.log(resp.status, data);
Responses
{
"date": "2026-06-13",
"scan": {
"limit": 1000,
"remaining": 958,
"used": 42
}
}
{
"error": "authentication_required"
}
Try it
/api/status.json
no auth
Platform status / uptime + pipeline latency
Machine-readable health snapshot of the ZerophishEx platform — drop into an uptime monitor (Pingdom, UptimeRobot, Datadog) to distinguish a network blip from a real outage. Reports an `overall` roll-up plus per-component status (`up` / `degraded` / `down` / `unknown`) for the database, the scan pipeline, and the detonator sidecar. The `pipeline` block carries Oban `:default`-queue job-state counts and p50/p95 completion latency (ms) over the last hour. Only aggregate health is exposed — no per-org or otherwise sensitive internals. The snapshot is cached server-side for 30 seconds, so it is safe to poll frequently. No API key required. The human-readable version lives at `/status`.
Code samples
curl "http://zerophish.ai/api/status.json"
import requests
API_KEY = "zp_live_…" # from /app/settings/api-keys
resp = requests.get(
"http://zerophish.ai/api/status.json",
)
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/status.json", {
});
const data = await resp.json();
console.log(resp.status, data);
Responses
{
"components": {
"database": {
"detail": "connected",
"status": "up"
},
"detonator": {
"detail": "reachable",
"status": "up"
},
"pipeline": {
"p50_ms": 7200,
"p95_ms": 18400,
"states": {
"available": 3,
"completed": 142
},
"status": "up"
}
},
"generated_at": "2026-06-27T12:00:00Z",
"overall": "up"
}
Try it
/api/feeds/csv
required · scope: read
CSV export of confirmed phishing indicators
The same confirmed-phishing set as the STIX feed (your organisation, last 90 days) as a CSV — one row per indicator with url, normalized_url, verdict, confidence, phishing_score, brands, first_seen, and jarm. Ready to pivot in a spreadsheet or load into a SIEM. The organisation is taken from the API key automatically. Supports ETag / If-None-Match conditional polling: responses carry a weak `ETag` + `Last-Modified`; re-send the ETag in `If-None-Match` and an unchanged feed returns `304 Not Modified` (empty body). Requires the `read` scope.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| brand | query | string | no | Filter to confirmed-phishing scans whose `brands` field contains this case-insensitive substring (e.g. only the phishing targeting Acme). Free-text match; absent = all brands. |
| sector | query | string | no | Filter to confirmed-phishing scans targeting any brand in this industry sector — expands to every catalogue brand in the vertical (e.g. `finance` covers PayPal, Stripe and the named banks). One of: crypto, ecommerce, entertainment, finance, gaming, government, shipping, social, technology. Unknown sectors match nothing; absent = all sectors. |
| campaign | query | string | no | Filter to confirmed-phishing scans clustered into the given campaign (its UUID, from `GET /api/campaigns`) — pull one entire campaign as a feed. Unclustered scans never match; absent = all campaigns. |
Code samples
curl "http://zerophish.ai/api/feeds/csv?brand=Acme" \
-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/csv",
headers={"Authorization": f"Bearer {API_KEY}"},
params={"brand": "Acme"},
)
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/csv?brand=Acme", {
headers: {
"Authorization": `Bearer ${API_KEY}`,
},
});
const data = await resp.json();
console.log(resp.status, data);
Responses
"url,normalized_url,verdict,confidence,phishing_score,brands,first_seen,jarm\r\nhttps://login.examplebank.com.verify.example/,login.examplebank.com.verify.example/,phishing,high,0.94,Example Bank,2026-05-30T12:01:55Z,27d40d40d29d40d1dc42d43d00041d"
""
{
"error": "authentication_required"
}
Try it
/api/feeds/misp.json
required · scope: read
MISP Event JSON of confirmed phishing indicators
The same confirmed-phishing set as the STIX feed (your organisation, last 90 days) as a MISP Event with one `url` Attribute per indicator — import straight into a MISP instance. The organisation is taken from the API key automatically. Supports ETag / If-None-Match conditional polling: responses carry a weak `ETag` + `Last-Modified`; re-send the ETag in `If-None-Match` and an unchanged feed returns `304 Not Modified` (empty body). Requires the `read` scope.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| brand | query | string | no | Filter to confirmed-phishing scans whose `brands` field contains this case-insensitive substring (e.g. only the phishing targeting Acme). Free-text match; absent = all brands. |
| sector | query | string | no | Filter to confirmed-phishing scans targeting any brand in this industry sector — expands to every catalogue brand in the vertical (e.g. `finance` covers PayPal, Stripe and the named banks). One of: crypto, ecommerce, entertainment, finance, gaming, government, shipping, social, technology. Unknown sectors match nothing; absent = all sectors. |
| campaign | query | string | no | Filter to confirmed-phishing scans clustered into the given campaign (its UUID, from `GET /api/campaigns`) — pull one entire campaign as a feed. Unclustered scans never match; absent = all campaigns. |
Code samples
curl "http://zerophish.ai/api/feeds/misp.json?brand=Acme" \
-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/misp.json",
headers={"Authorization": f"Bearer {API_KEY}"},
params={"brand": "Acme"},
)
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/misp.json?brand=Acme", {
headers: {
"Authorization": `Bearer ${API_KEY}`,
},
});
const data = await resp.json();
console.log(resp.status, data);
Responses
{
"Event": {
"Attribute": [
{
"category": "Payload delivery",
"comment": "phishing — Example Bank (confidence high)",
"to_ids": true,
"type": "url",
"value": "https://login.examplebank.com.verify.example/"
}
],
"analysis": "2",
"date": "2026-06-13",
"info": "ZerophishEx confirmed phishing — last 90 days",
"threat_level_id": "2"
}
}
""
{
"error": "authentication_required"
}
Try it
/api/feeds/txt
required · scope: read
Plaintext URL blocklist of confirmed phishing
The same confirmed-phishing set as the STIX feed (your organisation, last 90 days) as a bare newline-delimited URL list (text/plain) — one normalized URL per line. The most-consumed format for safe-browsing / AV / DNS-RPZ / browser-blocklist ingestion. Pass `?defang=1` to emit `hxxp`-defanged lines. The organisation is taken from the API key automatically. Supports ETag / If-None-Match conditional polling: responses carry a weak `ETag` + `Last-Modified`; re-send the ETag in `If-None-Match` and an unchanged feed returns `304 Not Modified` (empty body). Requires the `read` scope.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| defang | query | boolean | no | Emit hxxp-defanged lines (e.g. hxxps://paypa1[.]com) so the list is safe to paste without auto-linkifying. Pass 1/true/yes. |
| brand | query | string | no | Filter to confirmed-phishing scans whose `brands` field contains this case-insensitive substring (e.g. only the phishing targeting Acme). Free-text match; absent = all brands. |
| sector | query | string | no | Filter to confirmed-phishing scans targeting any brand in this industry sector — expands to every catalogue brand in the vertical (e.g. `finance` covers PayPal, Stripe and the named banks). One of: crypto, ecommerce, entertainment, finance, gaming, government, shipping, social, technology. Unknown sectors match nothing; absent = all sectors. |
| campaign | query | string | no | Filter to confirmed-phishing scans clustered into the given campaign (its UUID, from `GET /api/campaigns`) — pull one entire campaign as a feed. Unclustered scans never match; absent = all campaigns. |
Code samples
curl "http://zerophish.ai/api/feeds/txt?defang=1" \
-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/txt",
headers={"Authorization": f"Bearer {API_KEY}"},
params={"defang": "1"},
)
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/txt?defang=1", {
headers: {
"Authorization": `Bearer ${API_KEY}`,
},
});
const data = await resp.json();
console.log(resp.status, data);
Responses
"https://login.examplebank.com.verify.example/\nhttps://secure-paypa1.example/signin"
""
{
"error": "authentication_required"
}
Try it
/api/feeds/rpz
required · scope: read
BIND RPZ response-policy-zone (DNS firewall)
The same confirmed-phishing set as the STIX feed (your organisation, last 90 days) as a loadable ISC BIND RPZ (Response Policy Zone) master zone — the format abuse.ch / Spamhaus / ioc2rpz publish so operators slot phishing domains straight into a recursive resolver as a DNS firewall. Unlike `/api/feeds/txt` (one full URL per line), this emits a syntactically valid zone: a `$TTL`, an `SOA` whose serial is the newest prediction's `inserted_at` epoch (monotonic, so IXFR sees a strictly increasing serial), an `NS`, then one `<host> CNAME .` record (NXDOMAIN policy) per DISTINCT phishing host. NOTE: RPZ keys on hostnames, not URLs — path-specific phishing on a shared host blocks the WHOLE host. The organisation is taken from the API key automatically. Supports ETag / If-None-Match conditional polling: responses carry a weak `ETag` + `Last-Modified`; re-send the ETag in `If-None-Match` and an unchanged feed returns `304 Not Modified` (empty body). Requires the `read` scope.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| brand | query | string | no | Filter to confirmed-phishing scans whose `brands` field contains this case-insensitive substring (e.g. only the phishing targeting Acme). Free-text match; absent = all brands. |
| sector | query | string | no | Filter to confirmed-phishing scans targeting any brand in this industry sector — expands to every catalogue brand in the vertical (e.g. `finance` covers PayPal, Stripe and the named banks). One of: crypto, ecommerce, entertainment, finance, gaming, government, shipping, social, technology. Unknown sectors match nothing; absent = all sectors. |
| campaign | query | string | no | Filter to confirmed-phishing scans clustered into the given campaign (its UUID, from `GET /api/campaigns`) — pull one entire campaign as a feed. Unclustered scans never match; absent = all campaigns. |
Code samples
curl "http://zerophish.ai/api/feeds/rpz?brand=Acme" \
-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/rpz",
headers={"Authorization": f"Bearer {API_KEY}"},
params={"brand": "Acme"},
)
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/rpz?brand=Acme", {
headers: {
"Authorization": `Bearer ${API_KEY}`,
},
});
const data = await resp.json();
console.log(resp.status, data);
Responses
"$TTL 3600\n@ IN SOA ns.zerophish.ai. ns.zerophish.ai. (\n 1735300000 ; serial\n 3600 ; refresh\n 600 ; retry\n 604800 ; expire\n 3600 ; minimum\n)\n@ IN NS ns.zerophish.ai.\nlogin.examplebank.com.verify.example CNAME .\nsecure-paypa1.example CNAME ."
""
{
"error": "authentication_required"
}
Try it
/api/feeds/chrome-blocklist.json
required · scope: read
Chrome / Edge Enterprise URLBlocklist policy JSON
The same confirmed-phishing set as the STIX feed (your organisation, last 90 days) as a Chrome / Edge Enterprise `URLBlocklist` policy document — `{"URLBlocklist": [host, ...]}`, ready to push via the Google Admin Console or Microsoft Intune as a zero-cost, live-pollable browser-policy enforcement point. Keyed on DISTINCT phishing HOSTS (not URLs): each entry blocks every path on that host, matching the policy's prefix matcher. The host list is capped at 1000 (the Chrome URLBlocklist ceiling), newest first. Pass `?min_confidence=` to raise the `phishing_score` floor. The organisation is taken from the API key automatically. Supports ETag / If-None-Match conditional polling: responses carry a weak `ETag` + `Last-Modified`; re-send the ETag in `If-None-Match` and an unchanged feed returns `304 Not Modified` (empty body). Requires the `read` scope.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| min_confidence | query | number | no | Only include confirmed-phishing hosts whose `phishing_score` is at least this floor (0.0–1.0). Out-of-range/garbage values are ignored; absent = the whole confirmed set. |
| brand | query | string | no | Filter to confirmed-phishing scans whose `brands` field contains this case-insensitive substring (e.g. only the phishing targeting Acme). Free-text match; absent = all brands. |
| sector | query | string | no | Filter to confirmed-phishing scans targeting any brand in this industry sector — expands to every catalogue brand in the vertical (e.g. `finance` covers PayPal, Stripe and the named banks). One of: crypto, ecommerce, entertainment, finance, gaming, government, shipping, social, technology. Unknown sectors match nothing; absent = all sectors. |
| campaign | query | string | no | Filter to confirmed-phishing scans clustered into the given campaign (its UUID, from `GET /api/campaigns`) — pull one entire campaign as a feed. Unclustered scans never match; absent = all campaigns. |
Code samples
curl "http://zerophish.ai/api/feeds/chrome-blocklist.json?min_confidence=0.9" \
-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/chrome-blocklist.json",
headers={"Authorization": f"Bearer {API_KEY}"},
params={"min_confidence": "0.9"},
)
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/chrome-blocklist.json?min_confidence=0.9", {
headers: {
"Authorization": `Bearer ${API_KEY}`,
},
});
const data = await resp.json();
console.log(resp.status, data);
Responses
{
"URLBlocklist": [
"login.examplebank.com.verify.example",
"secure-paypa1.example"
]
}
""
{
"error": "authentication_required"
}
Try it
/api/feeds/proxy.pac
required · scope: read
Proxy auto-config (PAC) of confirmed phishing
The same confirmed-phishing set as the STIX feed (your organisation, last 90 days) as a proxy auto-config (`proxy.pac`) file — a `FindProxyForURL(url, host)` function that looks each request host up in a JS table of phishing hosts and returns a configurable `PROXY` directive (default an unroutable sink, so a match fails closed) for matches and `DIRECT` otherwise. A zero-cost browser/OS proxy-layer enforcement point with a live-poll mode. Keyed on DISTINCT phishing HOSTS, capped at 1000 (PAC files have a parse-size ceiling on some OS consumers). Pass `?proxy=host:port` to set the PROXY directive and `?min_confidence=` to raise the `phishing_score` floor. The organisation is taken from the API key automatically. Supports ETag / If-None-Match conditional polling: responses carry a weak `ETag` + `Last-Modified`; re-send the ETag in `If-None-Match` and an unchanged feed returns `304 Not Modified` (empty body). Requires the `read` scope.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| proxy | query | string | no | The `PROXY` directive (host:port) returned for matched phishing hosts. Sanitised to a host[:port] charset. Absent = 127.0.0.1:1 (an unroutable sink — matched requests fail closed). |
| min_confidence | query | number | no | Only include confirmed-phishing hosts whose `phishing_score` is at least this floor (0.0–1.0). Out-of-range/garbage values are ignored; absent = the whole confirmed set. |
| brand | query | string | no | Filter to confirmed-phishing scans whose `brands` field contains this case-insensitive substring (e.g. only the phishing targeting Acme). Free-text match; absent = all brands. |
| sector | query | string | no | Filter to confirmed-phishing scans targeting any brand in this industry sector — expands to every catalogue brand in the vertical (e.g. `finance` covers PayPal, Stripe and the named banks). One of: crypto, ecommerce, entertainment, finance, gaming, government, shipping, social, technology. Unknown sectors match nothing; absent = all sectors. |
| campaign | query | string | no | Filter to confirmed-phishing scans clustered into the given campaign (its UUID, from `GET /api/campaigns`) — pull one entire campaign as a feed. Unclustered scans never match; absent = all campaigns. |
Code samples
curl "http://zerophish.ai/api/feeds/proxy.pac?proxy=blackhole.example%3A3128" \
-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/proxy.pac",
headers={"Authorization": f"Bearer {API_KEY}"},
params={"proxy": "blackhole.example:3128"},
)
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/proxy.pac?proxy=blackhole.example%3A3128", {
headers: {
"Authorization": `Bearer ${API_KEY}`,
},
});
const data = await resp.json();
console.log(resp.status, data);
Responses
"var phishingHosts = {\n \"login.examplebank.com.verify.example\": 1,\n \"secure-paypa1.example\": 1,\n};\n\nfunction FindProxyForURL(url, host) {\n host = host.toLowerCase();\n if (phishingHosts[host]) { return \"PROXY 127.0.0.1:1\"; }\n return \"DIRECT\";\n}\n"
""
{
"error": "authentication_required"
}
Try it
/api/feeds/rss.xml
required · scope: read
RSS 2.0 feed of recent phishing verdicts
The same confirmed-phishing set as the STIX feed (your organisation, last 90 days, newest first, capped at 100 items) as an RSS 2.0 feed — the lowest-friction distribution channel, ingested natively by Feedly, Slack and MISP. Each `<item>` carries a title ("phishing — <brand>"), the canonical `/scans/{id}` permalink as both `<link>` and `<guid>`, a `<pubDate>`, and the scan summary as `<description>`. The organisation is taken from the API key automatically. Supports ETag / If-None-Match conditional polling: responses carry a weak `ETag` + `Last-Modified`; re-send the ETag in `If-None-Match` and an unchanged feed returns `304 Not Modified` (empty body). Requires the `read` scope.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| brand | query | string | no | Filter to confirmed-phishing scans whose `brands` field contains this case-insensitive substring (e.g. only the phishing targeting Acme). Free-text match; absent = all brands. |
| sector | query | string | no | Filter to confirmed-phishing scans targeting any brand in this industry sector — expands to every catalogue brand in the vertical (e.g. `finance` covers PayPal, Stripe and the named banks). One of: crypto, ecommerce, entertainment, finance, gaming, government, shipping, social, technology. Unknown sectors match nothing; absent = all sectors. |
| campaign | query | string | no | Filter to confirmed-phishing scans clustered into the given campaign (its UUID, from `GET /api/campaigns`) — pull one entire campaign as a feed. Unclustered scans never match; absent = all campaigns. |
Code samples
curl "http://zerophish.ai/api/feeds/rss.xml?brand=Acme" \
-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/rss.xml",
headers={"Authorization": f"Bearer {API_KEY}"},
params={"brand": "Acme"},
)
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/rss.xml?brand=Acme", {
headers: {
"Authorization": `Bearer ${API_KEY}`,
},
});
const data = await resp.json();
console.log(resp.status, data);
Responses
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<rss version=\"2.0\"><channel><title>ZerophishEx confirmed phishing</title><item><title>phishing — Example Bank</title><link>https://zerophish.ai/scans/b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f</link><guid isPermaLink=\"false\">b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f</guid></item></channel></rss>"
""
{
"error": "authentication_required"
}
Try it
/api/feeds/atom.xml
required · scope: read
Atom 1.0 feed of recent phishing verdicts
The same confirmed-phishing set as the RSS feed (your organisation, last 90 days, newest first, capped at 100 items) as an Atom 1.0 feed. Each `<entry>` carries a title, the canonical `/scans/{id}` permalink, a stable `<id>`, `<published>`/`<updated>` timestamps, and the scan summary. The organisation is taken from the API key automatically. Supports ETag / If-None-Match conditional polling: responses carry a weak `ETag` + `Last-Modified`; re-send the ETag in `If-None-Match` and an unchanged feed returns `304 Not Modified` (empty body). Requires the `read` scope.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| brand | query | string | no | Filter to confirmed-phishing scans whose `brands` field contains this case-insensitive substring (e.g. only the phishing targeting Acme). Free-text match; absent = all brands. |
| sector | query | string | no | Filter to confirmed-phishing scans targeting any brand in this industry sector — expands to every catalogue brand in the vertical (e.g. `finance` covers PayPal, Stripe and the named banks). One of: crypto, ecommerce, entertainment, finance, gaming, government, shipping, social, technology. Unknown sectors match nothing; absent = all sectors. |
| campaign | query | string | no | Filter to confirmed-phishing scans clustered into the given campaign (its UUID, from `GET /api/campaigns`) — pull one entire campaign as a feed. Unclustered scans never match; absent = all campaigns. |
Code samples
curl "http://zerophish.ai/api/feeds/atom.xml?brand=Acme" \
-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/atom.xml",
headers={"Authorization": f"Bearer {API_KEY}"},
params={"brand": "Acme"},
)
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/atom.xml?brand=Acme", {
headers: {
"Authorization": `Bearer ${API_KEY}`,
},
});
const data = await resp.json();
console.log(resp.status, data);
Responses
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<feed xmlns=\"http://www.w3.org/2005/Atom\"><title>ZerophishEx confirmed phishing</title><entry><title>phishing — Example Bank</title><link href=\"https://zerophish.ai/scans/b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f\"/><id>urn:zerophish:prediction:b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f</id></entry></feed>"
""
{
"error": "authentication_required"
}
Try it
/api/taxii2/
required · scope: read
TAXII 2.1 Discovery
TAXII 2.1 Discovery endpoint. Returns the server title and the list of API Roots. Point any TAXII 2.1-capable SIEM/TIP/SOAR (Microsoft Sentinel, MISP, OpenCTI, Google SecOps) at this URL with your API key as the bearer token and it will auto-poll the STIX 2.1 feed — no custom code. Responses use the TAXII media type `application/taxii+json;version=2.1`. Requires the `read` scope.
Code samples
curl "http://zerophish.ai/api/taxii2/" \
-H "Authorization: Bearer $ZEROPHISH_API_KEY"
import requests
API_KEY = "zp_live_…" # from /app/settings/api-keys
resp = requests.get(
"http://zerophish.ai/api/taxii2/",
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/taxii2/", {
headers: {
"Authorization": `Bearer ${API_KEY}`,
},
});
const data = await resp.json();
console.log(resp.status, data);
Responses
{
"api_roots": [
"https://zerophish.ai/api/taxii2/api/"
],
"default": "https://zerophish.ai/api/taxii2/api/",
"description": "TAXII 2.1 server exposing ZerophishEx confirmed-phishing indicators as STIX 2.1 objects.",
"title": "ZerophishEx TAXII Server"
}
{
"error": "authentication_required"
}
Try it
/api/taxii2/api/
required · scope: read
TAXII 2.1 API Root metadata
TAXII 2.1 API Root information — the supported TAXII versions and the maximum content length. Responses use the TAXII media type `application/taxii+json;version=2.1`. Requires the `read` scope.
Code samples
curl "http://zerophish.ai/api/taxii2/api/" \
-H "Authorization: Bearer $ZEROPHISH_API_KEY"
import requests
API_KEY = "zp_live_…" # from /app/settings/api-keys
resp = requests.get(
"http://zerophish.ai/api/taxii2/api/",
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/taxii2/api/", {
headers: {
"Authorization": `Bearer ${API_KEY}`,
},
});
const data = await resp.json();
console.log(resp.status, data);
Responses
{
"description": "Confirmed-phishing threat intelligence.",
"max_content_length": 104857600,
"title": "ZerophishEx API Root",
"versions": [
"application/taxii+json;version=2.1"
]
}
{
"error": "authentication_required"
}
Try it
/api/taxii2/api/collections/
required · scope: read
TAXII 2.1 Collections list
Lists the TAXII Collections available to your API key. ZerophishEx exposes exactly one read-only collection per organisation, whose id is your organisation id — derived from the API key, so a key can only ever see its own collection. Responses use the TAXII media type `application/taxii+json;version=2.1`. Requires the `read` scope.
Code samples
curl "http://zerophish.ai/api/taxii2/api/collections/" \
-H "Authorization: Bearer $ZEROPHISH_API_KEY"
import requests
API_KEY = "zp_live_…" # from /app/settings/api-keys
resp = requests.get(
"http://zerophish.ai/api/taxii2/api/collections/",
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/taxii2/api/collections/", {
headers: {
"Authorization": `Bearer ${API_KEY}`,
},
});
const data = await resp.json();
console.log(resp.status, data);
Responses
{
"collections": [
{
"can_read": true,
"can_write": false,
"id": "8c1f…org-uuid",
"media_types": [
"application/stix+json;version=2.1"
],
"title": "Confirmed phishing"
}
]
}
{
"error": "authentication_required"
}
Try it
/api/taxii2/api/collections/:id/
required · scope: read
TAXII 2.1 Collection metadata
Returns metadata for a single TAXII Collection. The `id` must be your organisation's collection id (as returned by the Collections list) — any other id is a 404, so collections can't be enumerated across orgs. Responses use the TAXII media type `application/taxii+json;version=2.1`. Requires the `read` scope.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Your organisation's collection id. |
Code samples
curl "http://zerophish.ai/api/taxii2/api/collections/8c1f…org-uuid/" \
-H "Authorization: Bearer $ZEROPHISH_API_KEY"
import requests
API_KEY = "zp_live_…" # from /app/settings/api-keys
resp = requests.get(
"http://zerophish.ai/api/taxii2/api/collections/8c1f…org-uuid/",
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/taxii2/api/collections/8c1f…org-uuid/", {
headers: {
"Authorization": `Bearer ${API_KEY}`,
},
});
const data = await resp.json();
console.log(resp.status, data);
Responses
{
"can_read": true,
"can_write": false,
"id": "8c1f…org-uuid",
"media_types": [
"application/stix+json;version=2.1"
],
"title": "Confirmed phishing"
}
{
"error": "authentication_required"
}
{
"http_status": "404",
"title": "Collection not found"
}
Try it
/api/taxii2/api/collections/:id/objects/
required · scope: read
TAXII 2.1 Collection objects (STIX 2.1)
Returns the STIX 2.1 objects in the collection, wrapped in a TAXII `envelope`. These are the same confirmed-phishing indicators (plus campaign/infrastructure/observable/relationship objects) as `GET /api/feeds/stix.json`, scoped to your organisation, from the last 90 days. Supports paging via `limit` (1–200, default 100) and a `next` cursor (echo back the `next` value from the previous response, or pass `offset` directly), and an `added_after` exclusive ISO-8601 timestamp filter. Override the TLP marking with `?tlp=clear|green|amber|red` (default `amber`). The response carries `X-TAXII-Date-Added-First`/`-Last` headers for the returned page. Supports ETag / If-None-Match conditional polling: responses carry a weak `ETag` + `Last-Modified` (over the whole collection, independent of paging), and re-sending the ETag in `If-None-Match` returns `304 Not Modified` (empty body) while the collection is unchanged. Responses use the TAXII media type `application/taxii+json;version=2.1`. Requires the `read` scope.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Your organisation's collection id. |
| limit | query | integer | no | Max objects to return (1–200, default 100). |
| next | query | string | no | Paging cursor — echo back the `next` value from the previous response to fetch the next page. |
| added_after | query | string | no | Only objects added strictly after this ISO-8601 timestamp. |
| tlp | query | string | no | Traffic Light Protocol marking applied to every object: `clear`, `green`, `amber` (default), or `red`. |
Code samples
curl "http://zerophish.ai/api/taxii2/api/collections/8c1f…org-uuid/objects/?limit=100" \
-H "Authorization: Bearer $ZEROPHISH_API_KEY"
import requests
API_KEY = "zp_live_…" # from /app/settings/api-keys
resp = requests.get(
"http://zerophish.ai/api/taxii2/api/collections/8c1f…org-uuid/objects/",
headers={"Authorization": f"Bearer {API_KEY}"},
params={"limit": 100},
)
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/taxii2/api/collections/8c1f…org-uuid/objects/?limit=100", {
headers: {
"Authorization": `Bearer ${API_KEY}`,
},
});
const data = await resp.json();
console.log(resp.status, data);
Responses
{
"more": false,
"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"
}
]
}
""
{
"error": "authentication_required"
}
{
"http_status": "404",
"title": "Collection not found"
}